diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index c719870..ebacf58 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -16,12 +16,11 @@ public final class FeedUIComposer { public static func feedComposedWith( feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher - ) -> FeedViewController { + ) -> ListViewController { let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) - let feedController = makeFeedViewController( - delegate: presentationAdapter, - title: FeedPresenter.title) + let feedController = makeFeedViewController(title: FeedPresenter.title) + feedController.onRefresh = presentationAdapter.loadResource presentationAdapter.presenter = LoadResourcePresenter( resourceView: FeedViewAdapter( @@ -34,11 +33,10 @@ public final class FeedUIComposer { return feedController } - private static func makeFeedViewController(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController { - let bundle = Bundle(for: FeedViewController.self) + private static func makeFeedViewController(title: String) -> ListViewController { + let bundle = Bundle(for: ListViewController.self) let storyboard = UIStoryboard(name: "Feed", bundle: bundle) - let feedController = storyboard.instantiateInitialViewController() as! FeedViewController - feedController.delegate = delegate + let feedController = storyboard.instantiateInitialViewController() as! ListViewController feedController.title = title return feedController } diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index 80791fc..ab615a6 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -8,12 +8,12 @@ import EssentialFeed import EssentialFeediOS final class FeedViewAdapter: ResourceView { - private weak var controller: FeedViewController? + private weak var controller: ListViewController? private let imageLoader: (URL) -> FeedImageDataLoader.Publisher private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> - init(controller: FeedViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { + init(controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { self.controller = controller self.imageLoader = imageLoader } @@ -34,7 +34,7 @@ final class FeedViewAdapter: ResourceView { errorView: WeakRefVirtualProxy(view), mapper: UIImage.tryMake) - return view + return CellController(id: model, view) }) } } diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index 77af513..744ea3b 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -35,12 +35,6 @@ final class LoadResourcePresentationAdapter { } } -extension LoadResourcePresentationAdapter: FeedViewControllerDelegate { - func didRequestFeedRefresh() { - loadResource() - } -} - extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { func didRequestImage() { loadResource() diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 2ded0ee..691d7a2 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -58,13 +58,13 @@ class FeedAcceptanceTests: XCTestCase { private func launch( httpClient: HTTPClientStub = .offline, store: InMemoryFeedStore = .empty - ) -> FeedViewController { + ) -> ListViewController { let sut = SceneDelegate(httpClient: httpClient, store: store) sut.window = UIWindow() sut.configureWindow() let nav = sut.window?.rootViewController as? UINavigationController - let vc = nav?.topViewController as! FeedViewController + let vc = nav?.topViewController as! ListViewController vc.simulateAppearance() return vc } diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 1c12062..2a14230 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -118,6 +118,19 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.errorMessage, nil) } + func test_tapOnErrorView_hidesErrorMessage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeFeedLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, loadError) + + sut.simulateErrorViewTap() + XCTAssertEqual(sut.errorMessage, nil) + } + func test_feedImageView_loadsImageURLWhenVisible() { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) @@ -354,7 +367,7 @@ final class FeedUIIntegrationTests: XCTestCase { // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { let loader = LoaderSpy() let sut = FeedUIComposer.feedComposedWith(feedLoader: loader.loadPublisher, imageLoader: loader.loadImageDataPublisher) trackForMemoryLeaks(loader, file: file, line: line) diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift index 8d8f6eb..5aecd12 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift @@ -9,7 +9,7 @@ import EssentialFeediOS extension FeedUIIntegrationTests { - func assertThat(_ sut: FeedViewController, isRendering feed: [FeedImage], file: StaticString = #file, line: UInt = #line) { + func assertThat(_ sut: ListViewController, isRendering feed: [FeedImage], file: StaticString = #file, line: UInt = #line) { sut.view.enforceLayoutCycle() guard sut.numberOfRenderedFeedImageViews() == feed.count else { @@ -23,7 +23,7 @@ extension FeedUIIntegrationTests { executeRunLoopToCleanUpReferences() } - func assertThat(_ sut: FeedViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #file, line: UInt = #line) { + func assertThat(_ sut: ListViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #file, line: UInt = #line) { let view = sut.feedImageView(at: index) guard let cell = view as? FeedImageCell else { diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift index 3ffc8c4..0896610 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift @@ -6,7 +6,7 @@ import UIKit import EssentialFeediOS -extension FeedViewController { +extension ListViewController { func simulateAppearance() { if !isViewLoaded { loadViewIfNeeded() @@ -76,8 +76,12 @@ extension FeedViewController { return simulateFeedImageViewVisible(at: index)?.renderedImage } + func simulateErrorViewTap() { + errorView.simulateTap() + } + var errorMessage: String? { - return errorView?.message + return errorView.message } var isShowingLoadingIndicator: Bool { @@ -85,7 +89,7 @@ extension FeedViewController { } func numberOfRenderedFeedImageViews() -> Int { - return tableView.numberOfRows(inSection: feedImagesSection) + tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: feedImagesSection) } func feedImageView(at row: Int) -> UITableViewCell? { diff --git a/EssentialApp/EssentialAppTests/SceneDelegateTests.swift b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift index eb2ca6a..b5fa2fd 100644 --- a/EssentialApp/EssentialAppTests/SceneDelegateTests.swift +++ b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift @@ -30,7 +30,7 @@ class SceneDelegateTests: XCTestCase { let topController = rootNavigation?.topViewController XCTAssertNotNil(rootNavigation, "Expected a navigation controller as root, got \(String(describing: root)) instead") - XCTAssertTrue(topController is FeedViewController, "Expected a feed controller as top view controller, got \(String(describing: topController)) instead") + XCTAssertTrue(topController is ListViewController, "Expected a feed controller as top view controller, got \(String(describing: topController)) instead") } } diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 20059d5..524b5c8 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -22,6 +22,20 @@ 5B107E032BF5BB2100927709 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; 5B107E092BF5BB2100927709 /* EssentialFeed.h in Headers */ = {isa = PBXBuildFile; fileRef = 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B107E122BF5BB4200927709 /* FeedImage.swift */; }; + 5B1926292D89031F006C9C65 /* FEED_WITH_CONTENT_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926242D89031F006C9C65 /* FEED_WITH_CONTENT_dark.png */; }; + 5B19262A2D89031F006C9C65 /* FEED_WITH_CONTENT_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926252D89031F006C9C65 /* FEED_WITH_CONTENT_light.png */; }; + 5B19262B2D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926262D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png */; }; + 5B19262C2D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926282D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png */; }; + 5B19262D2D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926272D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png */; }; + 5B1926312D89033F006C9C65 /* IMAGE_COMMENTS_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B19262F2D89033F006C9C65 /* IMAGE_COMMENTS_light.png */; }; + 5B1926322D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926302D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png */; }; + 5B1926332D89033F006C9C65 /* IMAGE_COMMENTS_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B19262E2D89033F006C9C65 /* IMAGE_COMMENTS_dark.png */; }; + 5B1926392D890348006C9C65 /* EMPTY_LIST_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926352D890348006C9C65 /* EMPTY_LIST_light.png */; }; + 5B19263A2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926382D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png */; }; + 5B19263B2D890348006C9C65 /* EMPTY_LIST_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926342D890348006C9C65 /* EMPTY_LIST_dark.png */; }; + 5B19263C2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926372D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png */; }; + 5B19263D2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926362D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png */; }; + 5B19263F2D891F7F006C9C65 /* UIView+Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B19263E2D891F7F006C9C65 /* UIView+Container.swift */; }; 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */; }; 5B1C4FC92C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4FC82C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift */; }; 5B1C4FCA2C057236003F0429 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; @@ -32,7 +46,7 @@ 5B6992912CFE85E400DD47E9 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; platformFilter = ios; }; 5B6992922CFE85E400DD47E9 /* EssentialFeed.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5B6992972CFE8D8B00DD47E9 /* XCTestCase+MemoryLeakTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */; }; - 5B6992AD2D012C7200DD47E9 /* FeedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */; }; + 5B6992AD2D012C7200DD47E9 /* ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992AC2D012C7200DD47E9 /* ListViewController.swift */; }; 5B6992D72D03F23700DD47E9 /* FeedImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */; }; 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */; }; 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */; }; @@ -53,6 +67,12 @@ 5B73494A2D85337A007F7D5D /* ImageCommentsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349492D85337A007F7D5D /* ImageCommentsPresenter.swift */; }; 5B73494C2D853421007F7D5D /* ImageComments.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B73494B2D853421007F7D5D /* ImageComments.xcstrings */; }; 5B73494E2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73494D2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift */; }; + 5B7349562D879EAC007F7D5D /* ListSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349552D879EAC007F7D5D /* ListSnapshotTests.swift */; }; + 5B73497B2D87A34A007F7D5D /* ImageCommentsSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73497A2D87A34A007F7D5D /* ImageCommentsSnapshotTests.swift */; }; + 5B73497F2D87B049007F7D5D /* ImageCommentCellController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73497E2D87B049007F7D5D /* ImageCommentCellController.swift */; }; + 5B7349822D87B1E8007F7D5D /* ImageCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349812D87B1E8007F7D5D /* ImageCommentCell.swift */; }; + 5B7349842D87B2D7007F7D5D /* ImageComments.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5B7349832D87B2D7007F7D5D /* ImageComments.storyboard */; }; + 5B73498B2D87CF38007F7D5D /* CellController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73498A2D87CF38007F7D5D /* CellController.swift */; }; 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD192D649D0D007478DC /* ErrorView.swift */; }; 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */; }; 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */; }; @@ -179,6 +199,20 @@ 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EssentialFeed.h; sourceTree = ""; }; 5B107E022BF5BB2100927709 /* EssentialFeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B107E122BF5BB4200927709 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; + 5B1926242D89031F006C9C65 /* FEED_WITH_CONTENT_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_CONTENT_dark.png; sourceTree = ""; }; + 5B1926252D89031F006C9C65 /* FEED_WITH_CONTENT_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_CONTENT_light.png; sourceTree = ""; }; + 5B1926262D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_CONTENT_light_extraExtraExtraLarge.png; sourceTree = ""; }; + 5B1926272D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_FAILED_IMAGE_LOADING_dark.png; sourceTree = ""; }; + 5B1926282D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_FAILED_IMAGE_LOADING_light.png; sourceTree = ""; }; + 5B19262E2D89033F006C9C65 /* IMAGE_COMMENTS_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = IMAGE_COMMENTS_dark.png; sourceTree = ""; }; + 5B19262F2D89033F006C9C65 /* IMAGE_COMMENTS_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = IMAGE_COMMENTS_light.png; sourceTree = ""; }; + 5B1926302D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = IMAGE_COMMENTS_light_extraExtraExtraLarge.png; sourceTree = ""; }; + 5B1926342D890348006C9C65 /* EMPTY_LIST_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = EMPTY_LIST_dark.png; sourceTree = ""; }; + 5B1926352D890348006C9C65 /* EMPTY_LIST_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = EMPTY_LIST_light.png; sourceTree = ""; }; + 5B1926362D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LIST_WITH_ERROR_MESSAGE_dark.png; sourceTree = ""; }; + 5B1926372D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LIST_WITH_ERROR_MESSAGE_light.png; sourceTree = ""; }; + 5B1926382D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png; sourceTree = ""; }; + 5B19263E2D891F7F006C9C65 /* UIView+Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Container.swift"; sourceTree = ""; }; 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHTTPClient.swift; sourceTree = ""; }; 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeed.xctestplan; sourceTree = ""; }; 5B1C4FC62C057236003F0429 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -188,7 +222,7 @@ 5B4BAE7D2CFBA0EE00CE079A /* EssentialFeediOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeediOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B4BAE8F2CFBA12A00CE079A /* EssentialFeediOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EssentialFeediOS.h; sourceTree = ""; }; 5B4BAE922CFBA29600CE079A /* EssentialFeediOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = EssentialFeediOS.xctestplan; path = EssentialFeediOSTests/EssentialFeediOS.xctestplan; sourceTree = SOURCE_ROOT; }; - 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewController.swift; sourceTree = ""; }; + 5B6992AC2D012C7200DD47E9 /* ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewController.swift; sourceTree = ""; }; 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = ""; }; 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Shimmering.swift"; sourceTree = ""; }; 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataLoader.swift; sourceTree = ""; }; @@ -207,6 +241,12 @@ 5B7349492D85337A007F7D5D /* ImageCommentsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsPresenter.swift; sourceTree = ""; }; 5B73494B2D853421007F7D5D /* ImageComments.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = ImageComments.xcstrings; sourceTree = ""; }; 5B73494D2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsLocalizationTests.swift; sourceTree = ""; }; + 5B7349552D879EAC007F7D5D /* ListSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSnapshotTests.swift; sourceTree = ""; }; + 5B73497A2D87A34A007F7D5D /* ImageCommentsSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsSnapshotTests.swift; sourceTree = ""; }; + 5B73497E2D87B049007F7D5D /* ImageCommentCellController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentCellController.swift; sourceTree = ""; }; + 5B7349812D87B1E8007F7D5D /* ImageCommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentCell.swift; sourceTree = ""; }; + 5B7349832D87B2D7007F7D5D /* ImageComments.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ImageComments.storyboard; sourceTree = ""; }; + 5B73498A2D87CF38007F7D5D /* CellController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellController.swift; sourceTree = ""; }; 5B74FD192D649D0D007478DC /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Helpers.swift"; sourceTree = ""; }; 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenterTests.swift; sourceTree = ""; }; @@ -433,9 +473,11 @@ 5B4BAE8D2CFBA12700CE079A /* EssentialFeediOSTests */ = { isa = PBXGroup; children = ( - 5BB735112D7CD30C00189186 /* Helpers */, 5B4BAE922CFBA29600CE079A /* EssentialFeediOS.xctestplan */, - 5BBDA1AC2D7CCA8000D68DF0 /* FeedSnapshotTests.swift */, + 5BB735112D7CD30C00189186 /* Helpers */, + 5B7349542D879E00007F7D5D /* Shared UI */, + 5B7349792D87A2F6007F7D5D /* Image Comments UI */, + 5B73494F2D87973B007F7D5D /* Feed UI */, ); path = EssentialFeediOSTests; sourceTree = ""; @@ -444,6 +486,8 @@ isa = PBXGroup; children = ( 5B4BAE8F2CFBA12A00CE079A /* EssentialFeediOS.h */, + 5B7349502D879C2E007F7D5D /* Shared UI */, + 5B73497C2D87AFF6007F7D5D /* Image Comments UI */, 5B6992F72D0932FA00DD47E9 /* Feed UI */, ); path = EssentialFeediOS; @@ -459,7 +503,6 @@ 5B6992EB2D091F0A00DD47E9 /* Controllers */ = { isa = PBXGroup; children = ( - 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */, 5B06826D2D0A5E59009749F3 /* FeedImageCellController.swift */, ); path = Controllers; @@ -470,7 +513,6 @@ children = ( 5B74FD1B2D64A6DE007478DC /* Helpers */, 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */, - 5B74FD192D649D0D007478DC /* ErrorView.swift */, 5B8AB34F2D51AA1B00CDDDEB /* Feed.storyboard */, 5B8AB3652D51AB6200CDDDEB /* Feed.xcassets */, ); @@ -575,14 +617,136 @@ path = "Image Comments Presentation"; sourceTree = ""; }; - 5B74FD1B2D64A6DE007478DC /* Helpers */ = { + 5B73494F2D87973B007F7D5D /* Feed UI */ = { isa = PBXGroup; children = ( - 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */, + 5B7349572D87A144007F7D5D /* snapshots */, + 5BBDA1AC2D7CCA8000D68DF0 /* FeedSnapshotTests.swift */, + ); + path = "Feed UI"; + sourceTree = ""; + }; + 5B7349502D879C2E007F7D5D /* Shared UI */ = { + isa = PBXGroup; + children = ( + 5B7349512D879C57007F7D5D /* Controllers */, + 5B7349522D879C82007F7D5D /* Views */, + ); + path = "Shared UI"; + sourceTree = ""; + }; + 5B7349512D879C57007F7D5D /* Controllers */ = { + isa = PBXGroup; + children = ( + 5B6992AC2D012C7200DD47E9 /* ListViewController.swift */, + 5B73498A2D87CF38007F7D5D /* CellController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + 5B7349522D879C82007F7D5D /* Views */ = { + isa = PBXGroup; + children = ( + 5B7349532D879CAE007F7D5D /* Helpers */, + 5B74FD192D649D0D007478DC /* ErrorView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 5B7349532D879CAE007F7D5D /* Helpers */ = { + isa = PBXGroup; + children = ( + 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */, 5B8AB3672D52D73200CDDDEB /* UITableView+Dequeueing.swift */, 5BB735142D7CD9F900189186 /* UITableView+HeaderSizing.swift */, + 5B19263E2D891F7F006C9C65 /* UIView+Container.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 5B7349542D879E00007F7D5D /* Shared UI */ = { + isa = PBXGroup; + children = ( + 5B7349702D87A199007F7D5D /* snapshots */, + 5B7349552D879EAC007F7D5D /* ListSnapshotTests.swift */, + ); + path = "Shared UI"; + sourceTree = ""; + }; + 5B7349572D87A144007F7D5D /* snapshots */ = { + isa = PBXGroup; + children = ( + 5B1926242D89031F006C9C65 /* FEED_WITH_CONTENT_dark.png */, + 5B1926252D89031F006C9C65 /* FEED_WITH_CONTENT_light.png */, + 5B1926262D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png */, + 5B1926272D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png */, + 5B1926282D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png */, + ); + path = snapshots; + sourceTree = ""; + }; + 5B7349702D87A199007F7D5D /* snapshots */ = { + isa = PBXGroup; + children = ( + 5B1926342D890348006C9C65 /* EMPTY_LIST_dark.png */, + 5B1926352D890348006C9C65 /* EMPTY_LIST_light.png */, + 5B1926362D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png */, + 5B1926372D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png */, + 5B1926382D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png */, + ); + path = snapshots; + sourceTree = ""; + }; + 5B7349792D87A2F6007F7D5D /* Image Comments UI */ = { + isa = PBXGroup; + children = ( + 5B7349852D87C086007F7D5D /* snapshots */, + 5B73497A2D87A34A007F7D5D /* ImageCommentsSnapshotTests.swift */, + ); + path = "Image Comments UI"; + sourceTree = ""; + }; + 5B73497C2D87AFF6007F7D5D /* Image Comments UI */ = { + isa = PBXGroup; + children = ( + 5B73497D2D87B010007F7D5D /* Controllers */, + 5B7349802D87B1B1007F7D5D /* Views */, + ); + path = "Image Comments UI"; + sourceTree = ""; + }; + 5B73497D2D87B010007F7D5D /* Controllers */ = { + isa = PBXGroup; + children = ( + 5B73497E2D87B049007F7D5D /* ImageCommentCellController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + 5B7349802D87B1B1007F7D5D /* Views */ = { + isa = PBXGroup; + children = ( + 5B7349812D87B1E8007F7D5D /* ImageCommentCell.swift */, + 5B7349832D87B2D7007F7D5D /* ImageComments.storyboard */, + ); + path = Views; + sourceTree = ""; + }; + 5B7349852D87C086007F7D5D /* snapshots */ = { + isa = PBXGroup; + children = ( + 5B19262E2D89033F006C9C65 /* IMAGE_COMMENTS_dark.png */, + 5B19262F2D89033F006C9C65 /* IMAGE_COMMENTS_light.png */, + 5B1926302D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png */, + ); + path = snapshots; + sourceTree = ""; + }; + 5B74FD1B2D64A6DE007478DC /* Helpers */ = { + isa = PBXGroup; + children = ( + 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */, 5B8AB3692D52DC3600CDDDEB /* UIImageView+Animations.swift */, - 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */, ); path = Helpers; sourceTree = ""; @@ -940,6 +1104,7 @@ buildActionMask = 2147483647; files = ( 5B8AB3502D51AA1B00CDDDEB /* Feed.storyboard in Resources */, + 5B7349842D87B2D7007F7D5D /* ImageComments.storyboard in Resources */, 5B8AB3662D51AB6200CDDDEB /* Feed.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -948,6 +1113,19 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5B1926312D89033F006C9C65 /* IMAGE_COMMENTS_light.png in Resources */, + 5B1926322D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png in Resources */, + 5B1926332D89033F006C9C65 /* IMAGE_COMMENTS_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 */, + 5B19262C2D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png in Resources */, + 5B1926392D890348006C9C65 /* EMPTY_LIST_light.png in Resources */, + 5B19263A2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png in Resources */, + 5B19263B2D890348006C9C65 /* EMPTY_LIST_dark.png in Resources */, + 5B19263C2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png in Resources */, + 5B19263D2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png in Resources */, + 5B19262D2D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1057,12 +1235,16 @@ 5BB735152D7CD9F900189186 /* UITableView+HeaderSizing.swift in Sources */, 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */, 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */, + 5B19263F2D891F7F006C9C65 /* UIView+Container.swift in Sources */, + 5B73498B2D87CF38007F7D5D /* CellController.swift in Sources */, 5B8AB3682D52D73200CDDDEB /* UITableView+Dequeueing.swift in Sources */, 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */, 5B8AB36A2D52DC3600CDDDEB /* UIImageView+Animations.swift in Sources */, - 5B6992AD2D012C7200DD47E9 /* FeedViewController.swift in Sources */, + 5B6992AD2D012C7200DD47E9 /* ListViewController.swift in Sources */, 5B6992D72D03F23700DD47E9 /* FeedImageCell.swift in Sources */, + 5B73497F2D87B049007F7D5D /* ImageCommentCellController.swift in Sources */, 5B06826E2D0A5E59009749F3 /* FeedImageCellController.swift in Sources */, + 5B7349822D87B1E8007F7D5D /* ImageCommentCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1075,6 +1257,8 @@ 5BBDA1AD2D7CCA8000D68DF0 /* FeedSnapshotTests.swift in Sources */, 5BB735192D7D0CB000189186 /* XCTestCase+Snapshot.swift in Sources */, 5BB735172D7D0BEE00189186 /* UIViewController+Snapshot.swift in Sources */, + 5B7349562D879EAC007F7D5D /* ListSnapshotTests.swift in Sources */, + 5B73497B2D87A34A007F7D5D /* ImageCommentsSnapshotTests.swift in Sources */, 5BB735132D7CD33B00189186 /* UIImage+TestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index 880b991..dd541f0 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -11,9 +11,7 @@ public protocol FeedImageCellControllerDelegate { func didCancelImageRequest() } -public final class FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { - public typealias ResourceViewModel = UIImage - +public final class FeedImageCellController: NSObject { private let viewModel: FeedImageViewModel private let delegate: FeedImageCellControllerDelegate private var cell: FeedImageCell? @@ -22,8 +20,14 @@ public final class FeedImageCellController: ResourceView, ResourceLoadingView, R self.viewModel = viewModel self.delegate = delegate } +} + +extension FeedImageCellController: UITableViewDataSource, UITableViewDelegate, UITableViewDataSourcePrefetching { + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 1 + } - func view(in tableView: UITableView) -> UITableViewCell { + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { cell = tableView.dequeueReusableCell() cell?.onReuse = { [weak self] in self?.releaseCellForReuse() @@ -32,20 +36,39 @@ public final class FeedImageCellController: ResourceView, ResourceLoadingView, R cell?.locationLabel.text = viewModel.location cell?.descriptionLabel.text = viewModel.description cell?.feedImageView.image = nil - cell?.onRetry = delegate.didRequestImage + cell?.onRetry = { [weak self] in + self?.delegate.didRequestImage() + } delegate.didRequestImage() return cell! } - func preload() { + public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cancelLoad() + } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { delegate.didRequestImage() } - func cancelLoad() { + public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + cancelLoad() + } + + private func cancelLoad() { releaseCellForReuse() delegate.didCancelImageRequest() } + private func releaseCellForReuse() { + cell?.onReuse = nil + cell = nil + } +} + +extension FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { + public typealias ResourceViewModel = UIImage + public func display(_ viewModel: UIImage) { cell?.feedImageView.setImageAnimated(viewModel) } @@ -57,9 +80,4 @@ public final class FeedImageCellController: ResourceView, ResourceLoadingView, R public func display(_ viewModel: ResourceErrorViewModel) { cell?.feedImageRetryButton.isHidden = viewModel.message == nil } - - private func releaseCellForReuse() { - cell?.onReuse = nil - cell = nil - } } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift deleted file mode 100644 index 2a989d5..0000000 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2024 PortoCode. All Rights Reserved. -// - -import UIKit -import EssentialFeed - -public protocol FeedViewControllerDelegate { - func didRequestFeedRefresh() -} - -public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, ResourceLoadingView, ResourceErrorView { - @IBOutlet private(set) public var errorView: ErrorView? - - private var loadingControllers = [IndexPath: FeedImageCellController]() - - private var tableModel = [FeedImageCellController]() { - didSet { tableView.reloadData() } - } - - private var onViewIsAppearing: ((FeedViewController) -> Void)? - - public var delegate: FeedViewControllerDelegate? - - public override func viewDidLoad() { - super.viewDidLoad() - - // Note: Using `onViewIsAppearing` to defer `beginRefreshing()` until the view is fully visible. - // This ensures the spinner appears correctly, addressing a change in behavior introduced in iOS 17. - onViewIsAppearing = { vc in - vc.onViewIsAppearing = nil - vc.refresh() - } - } - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - tableView.sizeTableHeaderToFit() - } - - @IBAction private func refresh() { - delegate?.didRequestFeedRefresh() - } - - public func display(_ cellControllers: [FeedImageCellController]) { - loadingControllers = [:] - tableModel = cellControllers - } - - public func display(_ viewModel: ResourceLoadingViewModel) { - refreshControl?.update(isRefreshing: viewModel.isLoading) - } - - public func display(_ viewModel: ResourceErrorViewModel) { - errorView?.message = viewModel.message - } - - public override func viewIsAppearing(_ animated: Bool) { - super.viewIsAppearing(animated) - - onViewIsAppearing?(self) - } - - public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return tableModel.count - } - - public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - return cellController(forRowAt: indexPath).view(in: tableView) - } - - public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - cancelCellControllerLoad(forRowAt: indexPath) - } - - public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - indexPaths.forEach { indexPath in - cellController(forRowAt: indexPath).preload() - } - } - - public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - indexPaths.forEach(cancelCellControllerLoad) - } - - private func cellController(forRowAt indexPath: IndexPath) -> FeedImageCellController { - let controller = tableModel[indexPath.row] - loadingControllers[indexPath] = controller - return controller - } - - private func cancelCellControllerLoad(forRowAt indexPath: IndexPath) { - loadingControllers[indexPath]?.cancelLoad() - loadingControllers[indexPath] = nil - } -} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/ErrorView.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/ErrorView.swift deleted file mode 100644 index 4da4310..0000000 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Views/ErrorView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import UIKit - -public final class ErrorView: UIView { - @IBOutlet private(set) public var label: UILabel! - - public var message: String? { - get { return isVisible ? label.text : nil } - set { setMessageAnimated(newValue) } - } - - public override func awakeFromNib() { - super.awakeFromNib() - - label.text = nil - alpha = 0 - } - - private var isVisible: Bool { - return alpha > 0 - } - - private func setMessageAnimated(_ message: String?) { - if let message = message { - showAnimated(message) - } else { - hideMessageAnimated() - } - } - - private func showAnimated(_ message: String) { - label.text = message - - UIView.animate(withDuration: 0.25) { - self.alpha = 1 - } - } - - @IBAction private func hideMessageAnimated() { - UIView.animate( - withDuration: 0.25, - animations: { self.alpha = 0 }, - completion: { completed in - if completed { - self.label.text = nil - } - }) - } -} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard index 6c4bc8c..ec3f9e6 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard @@ -7,49 +7,22 @@ - + - - + + - - - - - - - - - - - - - - - - - - - + - + @@ -78,9 +51,9 @@ - - - - - - + diff --git a/EssentialFeed/EssentialFeediOS/Image Comments UI/Controllers/ImageCommentCellController.swift b/EssentialFeed/EssentialFeediOS/Image Comments UI/Controllers/ImageCommentCellController.swift new file mode 100644 index 0000000..0b7de1e --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Image Comments UI/Controllers/ImageCommentCellController.swift @@ -0,0 +1,27 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed + +public class ImageCommentCellController: NSObject, UITableViewDataSource { + private let model: ImageCommentViewModel + + public init(model: ImageCommentViewModel) { + self.model = model + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 1 + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: ImageCommentCell = tableView.dequeueReusableCell() + cell.messageLabel.text = model.message + cell.usernameLabel.text = model.username + cell.dateLabel.text = model.date + return cell + } +} diff --git a/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageCommentCell.swift b/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageCommentCell.swift new file mode 100644 index 0000000..77eeafb --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageCommentCell.swift @@ -0,0 +1,12 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit + +public final class ImageCommentCell: UITableViewCell { + @IBOutlet private(set) public var messageLabel: UILabel! + @IBOutlet private(set) public var usernameLabel: UILabel! + @IBOutlet private(set) public var dateLabel: UILabel! +} diff --git a/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageComments.storyboard b/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageComments.storyboard new file mode 100644 index 0000000..9ee4bd6 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageComments.storyboard @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift new file mode 100644 index 0000000..220fc03 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift @@ -0,0 +1,39 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit + +public struct CellController { + let id: AnyHashable + let dataSource: UITableViewDataSource + 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 + } +} + +extension CellController: Equatable { + public static func == (lhs: CellController, rhs: CellController) -> Bool { + lhs.id == rhs.id + } +} + +extension CellController: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift new file mode 100644 index 0000000..b174a22 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -0,0 +1,107 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed + +public final class ListViewController: UITableViewController, UITableViewDataSourcePrefetching, ResourceLoadingView, ResourceErrorView { + private(set) public var errorView = ErrorView() + + private lazy var dataSource: UITableViewDiffableDataSource = { + .init(tableView: tableView) { (tableView, index, controller) in + controller.dataSource.tableView(tableView, cellForRowAt: index) + } + }() + + private var onViewIsAppearing: ((ListViewController) -> Void)? + + public var onRefresh: (() -> Void)? + + public override func viewDidLoad() { + super.viewDidLoad() + + configureTableView() + + // Note: Using `onViewIsAppearing` to defer `beginRefreshing()` until the view is fully visible. + // This ensures the spinner appears correctly, addressing a change in behavior introduced in iOS 17. + onViewIsAppearing = { vc in + vc.onViewIsAppearing = nil + vc.refresh() + } + } + + private func configureTableView() { + dataSource.defaultRowAnimation = .fade + tableView.dataSource = dataSource + tableView.tableHeaderView = errorView.makeContainer() + + errorView.onHide = { [weak self] in + self?.tableView.beginUpdates() + self?.tableView.sizeTableHeaderToFit() + self?.tableView.endUpdates() + } + } + + public override func traitCollectionDidChange(_ previous: UITraitCollection?) { + if previous?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + tableView.reloadData() + } + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + tableView.sizeTableHeaderToFit() + } + + @IBAction private func refresh() { + onRefresh?() + } + + public func display(_ cellControllers: [CellController]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(cellControllers, toSection: 0) + + dataSource.applySnapshotUsingReloadData(snapshot) + } + + public func display(_ viewModel: ResourceLoadingViewModel) { + refreshControl?.update(isRefreshing: viewModel.isLoading) + } + + public func display(_ viewModel: ResourceErrorViewModel) { + errorView.message = viewModel.message + } + + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + onViewIsAppearing?(self) + } + + public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let dl = cellController(at: indexPath)?.delegate + dl?.tableView?(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach { indexPath in + let dsp = cellController(at: indexPath)?.dataSourcePrefetching + dsp?.tableView(tableView, prefetchRowsAt: [indexPath]) + } + } + + public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach { indexPath in + let dsp = cellController(at: indexPath)?.dataSourcePrefetching + dsp?.tableView?(tableView, cancelPrefetchingForRowsAt: [indexPath]) + } + } + + private func cellController(at indexPath: IndexPath) -> CellController? { + dataSource.itemIdentifier(for: indexPath) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Views/ErrorView.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Views/ErrorView.swift new file mode 100644 index 0000000..36eeda6 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Views/ErrorView.swift @@ -0,0 +1,91 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit + +public final class ErrorView: UIButton { + public var message: String? { + get { return isVisible ? configuration?.title : nil } + set { setMessageAnimated(newValue) } + } + + public var onHide: (() -> Void)? + + public override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + private var titleAttributes: AttributeContainer { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = NSTextAlignment.center + + var attributes = AttributeContainer() + attributes.paragraphStyle = paragraphStyle + attributes.font = UIFont.preferredFont(forTextStyle: .body) + return attributes + } + + private func configure() { + var configuration = Configuration.plain() + configuration.titlePadding = 0 + configuration.baseForegroundColor = .white + configuration.background.backgroundColor = .errorBackgroundColor + configuration.background.cornerRadius = 0 + self.configuration = configuration + + addTarget(self, action: #selector(hideMessageAnimated), for: .touchUpInside) + + hideMessage() + } + + private var isVisible: Bool { + return alpha > 0 + } + + private func setMessageAnimated(_ message: String?) { + if let message = message { + showAnimated(message) + } else { + hideMessageAnimated() + } + } + + private func showAnimated(_ message: String) { + configuration?.attributedTitle = AttributedString(message, attributes: titleAttributes) + + configuration?.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) + + UIView.animate(withDuration: 0.25) { + self.alpha = 1 + } + } + + @objc private func hideMessageAnimated() { + UIView.animate( + withDuration: 0.25, + animations: { self.alpha = 0 }, + completion: { completed in + if completed { self.hideMessage() } + }) + } + + private func hideMessage() { + alpha = 0 + configuration?.attributedTitle = nil + configuration?.contentInsets = .zero + onHide?() + } +} + +extension UIColor { + static var errorBackgroundColor: UIColor { + UIColor(red: 0.99951404330000004, green: 0.41759261489999999, blue: 0.4154433012, alpha: 1) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIRefreshControl+Helpers.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIRefreshControl+Helpers.swift similarity index 100% rename from EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIRefreshControl+Helpers.swift rename to EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIRefreshControl+Helpers.swift diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+Dequeueing.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+Dequeueing.swift similarity index 100% rename from EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+Dequeueing.swift rename to EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+Dequeueing.swift diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+HeaderSizing.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+HeaderSizing.swift similarity index 100% rename from EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+HeaderSizing.swift rename to EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+HeaderSizing.swift diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIView+Container.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIView+Container.swift new file mode 100644 index 0000000..3da438c --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIView+Container.swift @@ -0,0 +1,24 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit + +extension UIView { + public func makeContainer() -> UIView { + let container = UIView() + container.backgroundColor = .clear + container.addSubview(self) + + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + leadingAnchor.constraint(equalTo: container.leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), + topAnchor.constraint(equalTo: container.topAnchor), + container.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + return container + } +} diff --git a/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift similarity index 78% rename from EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift rename to EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift index 98d140f..abb5ede 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift @@ -9,15 +9,6 @@ import EssentialFeediOS class FeedSnapshotTests: XCTestCase { - func test_emptyFeed() { - let sut = makeSUT() - - sut.display(emptyFeed()) - - assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "EMPTY_FEED_light") - assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "EMPTY_FEED_dark") - } - func test_feedWithContent() { let sut = makeSUT() @@ -25,15 +16,7 @@ class FeedSnapshotTests: XCTestCase { assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "FEED_WITH_CONTENT_light") assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "FEED_WITH_CONTENT_dark") - } - - func test_feedWithErrorMessage() { - let sut = makeSUT() - - sut.display(.error(message: "This is a\nmulti-line\nerror message")) - - assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "FEED_WITH_ERROR_MESSAGE_light") - assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "FEED_WITH_ERROR_MESSAGE_dark") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light, contentSize: .extraExtraExtraLarge)), named: "FEED_WITH_CONTENT_light_extraExtraExtraLarge") } func test_feedWithFailedImageLoading() { @@ -47,20 +30,16 @@ class FeedSnapshotTests: XCTestCase { // MARK: - Helpers - private func makeSUT() -> FeedViewController { - let bundle = Bundle(for: FeedViewController.self) + private func makeSUT() -> ListViewController { + let bundle = Bundle(for: ListViewController.self) let storyboard = UIStoryboard(name: "Feed", bundle: bundle) - let controller = storyboard.instantiateInitialViewController() as! FeedViewController + let controller = storyboard.instantiateInitialViewController() as! ListViewController controller.loadViewIfNeeded() controller.tableView.showsVerticalScrollIndicator = false controller.tableView.showsHorizontalScrollIndicator = false return controller } - private func emptyFeed() -> [FeedImageCellController] { - return [] - } - private func feedWithContent() -> [ImageStub] { return [ ImageStub( @@ -93,12 +72,12 @@ class FeedSnapshotTests: XCTestCase { } -private extension FeedViewController { +private extension ListViewController { func display(_ stubs: [ImageStub]) { - let cells: [FeedImageCellController] = stubs.map { stub in + let cells: [CellController] = stubs.map { stub in let cellController = FeedImageCellController(viewModel: stub.viewModel, delegate: stub) stub.controller = cellController - return cellController + return CellController(id: UUID(), cellController) } display(cells) diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_dark.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_dark.png new file mode 100644 index 0000000..89694b6 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light.png new file mode 100644 index 0000000..b5c86fd Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light_extraExtraExtraLarge.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light_extraExtraExtraLarge.png new file mode 100644 index 0000000..2ca74ee Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light_extraExtraExtraLarge.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png new file mode 100644 index 0000000..fe0f66e Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png new file mode 100644 index 0000000..d523eb4 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Helpers/UIViewController+Snapshot.swift b/EssentialFeed/EssentialFeediOSTests/Helpers/UIViewController+Snapshot.swift index f45e7a3..6af02e9 100644 --- a/EssentialFeed/EssentialFeediOSTests/Helpers/UIViewController+Snapshot.swift +++ b/EssentialFeed/EssentialFeediOSTests/Helpers/UIViewController+Snapshot.swift @@ -17,7 +17,7 @@ struct SnapshotConfiguration { let layoutMargins: UIEdgeInsets let traitCollection: UITraitCollection - static func iPhone8(style: UIUserInterfaceStyle) -> SnapshotConfiguration { + static func iPhone8(style: UIUserInterfaceStyle, contentSize: UIContentSizeCategory = .medium) -> SnapshotConfiguration { return SnapshotConfiguration( size: CGSize(width: 375, height: 667), safeAreaInsets: UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0), @@ -25,7 +25,7 @@ struct SnapshotConfiguration { traitCollection: UITraitCollection { $0.forceTouchCapability = .available $0.layoutDirection = .leftToRight - $0.preferredContentSizeCategory = .medium + $0.preferredContentSizeCategory = contentSize $0.userInterfaceIdiom = .phone $0.horizontalSizeClass = .compact $0.verticalSizeClass = .regular diff --git a/EssentialFeed/EssentialFeediOSTests/Helpers/XCTestCase+Snapshot.swift b/EssentialFeed/EssentialFeediOSTests/Helpers/XCTestCase+Snapshot.swift index 0fdeded..5c148c8 100644 --- a/EssentialFeed/EssentialFeediOSTests/Helpers/XCTestCase+Snapshot.swift +++ b/EssentialFeed/EssentialFeediOSTests/Helpers/XCTestCase+Snapshot.swift @@ -37,6 +37,7 @@ extension XCTestCase { ) try snapshotData?.write(to: snapshotURL) + XCTFail("Record succeeded - use `assert` to compare the snapshot from now on.", file: file, line: line) } catch { XCTFail("Failed to record snapshot with error: \(error)", file: file, line: line) } diff --git a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift new file mode 100644 index 0000000..2fce172 --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift @@ -0,0 +1,64 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeediOS +@testable import EssentialFeed + +class ImageCommentsSnapshotTests: XCTestCase { + + func test_listWithComments() { + let sut = makeSUT() + + sut.display(comments()) + + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "IMAGE_COMMENTS_light") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "IMAGE_COMMENTS_dark") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light, contentSize: .extraExtraExtraLarge)), named: "IMAGE_COMMENTS_light_extraExtraExtraLarge") + } + + // MARK: - Helpers + + private func makeSUT() -> ListViewController { + let bundle = Bundle(for: ListViewController.self) + let storyboard = UIStoryboard(name: "ImageComments", bundle: bundle) + let controller = storyboard.instantiateInitialViewController() as! ListViewController + controller.loadViewIfNeeded() + controller.tableView.showsVerticalScrollIndicator = false + controller.tableView.showsHorizontalScrollIndicator = false + return controller + } + + private func comments() -> [CellController] { + commentControllers().map { CellController(id: UUID(), $0) } + } + + private func commentControllers() -> [ImageCommentCellController] { + return [ + ImageCommentCellController( + model: ImageCommentViewModel( + message: "The East Side Gallery is an open-air gallery in Berlin. It consists of a series of murals painted directly on a 1,316 m long remnant of the Berlin Wall, located near the centre of Berlin, on Mühlenstraße in Friedrichshain-Kreuzberg. The gallery has official status as a Denkmal, or heritage-protected landmark.", + date: "1000 years ago", + username: "a long long long long username" + ) + ), + ImageCommentCellController( + model: ImageCommentViewModel( + message: "East Side Gallery\nMemorial in Berlin, Germany", + date: "10 days ago", + username: "a username" + ) + ), + ImageCommentCellController( + model: ImageCommentViewModel( + message: "nice", + date: "1 hour ago", + username: "a." + ) + ), + ] + } + +} diff --git a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_dark.png b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_dark.png new file mode 100644 index 0000000..37353bc Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light.png b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light.png new file mode 100644 index 0000000..caa8c3f Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light_extraExtraExtraLarge.png b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light_extraExtraExtraLarge.png new file mode 100644 index 0000000..860e90e Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light_extraExtraExtraLarge.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift new file mode 100644 index 0000000..8fed510 --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift @@ -0,0 +1,46 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeediOS +@testable import EssentialFeed + +class ListSnapshotTests: XCTestCase { + + func test_emptyList() { + let sut = makeSUT() + + sut.display(emptyList()) + + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "EMPTY_LIST_light") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "EMPTY_LIST_dark") + } + + func test_listWithErrorMessage() { + let sut = makeSUT() + + sut.display(.error(message: "This is a\nmulti-line\nerror message")) + + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light)), named: "LIST_WITH_ERROR_MESSAGE_light") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .dark)), named: "LIST_WITH_ERROR_MESSAGE_dark") + assert(snapshot: sut.snapshot(for: .iPhone8(style: .light, contentSize: .extraExtraExtraLarge)), named: "LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge") + } + + // MARK: - Helpers + + private func makeSUT() -> ListViewController { + let controller = ListViewController() + controller.loadViewIfNeeded() + controller.tableView.separatorStyle = .none + controller.tableView.showsVerticalScrollIndicator = false + controller.tableView.showsHorizontalScrollIndicator = false + return controller + } + + private func emptyList() -> [CellController] { + return [] + } + +} diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/EMPTY_FEED_dark.png b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_dark.png similarity index 100% rename from EssentialFeed/EssentialFeediOSTests/snapshots/EMPTY_FEED_dark.png rename to EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_dark.png diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/EMPTY_FEED_light.png b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_light.png similarity index 100% rename from EssentialFeed/EssentialFeediOSTests/snapshots/EMPTY_FEED_light.png rename to EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_light.png diff --git a/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_dark.png b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_dark.png new file mode 100644 index 0000000..fea7299 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light.png b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light.png new file mode 100644 index 0000000..10a5104 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png new file mode 100644 index 0000000..b7495a2 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_dark.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_dark.png deleted file mode 100644 index 7a3cbc4..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_dark.png and /dev/null differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_light.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_light.png deleted file mode 100644 index ebee7f1..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_CONTENT_light.png and /dev/null differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_dark.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_dark.png deleted file mode 100644 index 8128792..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_dark.png and /dev/null differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_light.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_light.png deleted file mode 100644 index aa93c9f..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_ERROR_MESSAGE_light.png and /dev/null differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png deleted file mode 100644 index 0e3a284..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png and /dev/null differ diff --git a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png b/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png deleted file mode 100644 index b196241..0000000 Binary files a/EssentialFeed/EssentialFeediOSTests/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png and /dev/null differ