├── EssentialFeed ├── EssentialFeed │ ├── Feed Presentation │ │ ├── en.lproj │ │ │ └── Feed.strings │ │ ├── el.lproj │ │ │ └── Feed.strings │ │ ├── pt-BR.lproj │ │ │ └── Feed.strings │ │ ├── FeedViewModel.swift │ │ ├── FeedImageViewModel.swift │ │ ├── FeedImagePresenter.swift │ │ ├── ResourceErrorViewModel.swift │ │ └── FeedPresenter.swift │ ├── Image Comments Presentation │ │ ├── en.lproj │ │ │ └── ImageComments.strings │ │ ├── el.lproj │ │ │ └── ImageComments.strings │ │ ├── pt-BR.lproj │ │ │ └── ImageComments.strings │ │ └── ImageCommentsPresenter.swift │ ├── Shared Presentation │ │ ├── en.lproj │ │ │ └── Shared.strings │ │ ├── el.lproj │ │ │ └── Shared.strings │ │ ├── pt-BR.lproj │ │ │ └── Shared.strings │ │ ├── ResourceLoadingViewModel.swift │ │ ├── ResourceErrorView.swift │ │ ├── ResourceLoadingView.swift │ │ └── LoadResourcePresenter.swift │ ├── Feed API │ │ ├── RemoteFeedLoader.swift │ │ ├── Helpers │ │ │ └── HTTPURLResponse+StatusCode.swift │ │ ├── FeedItemsMapper.swift │ │ └── RemoteFeedImageDataLoader.swift │ ├── ImageComments API │ │ ├── RemoteImageCommentsLoader.swift │ │ └── ImageCommentsMapper.swift │ ├── Feed Feature │ │ ├── FeedCache.swift │ │ ├── FeedImageDataCache.swift │ │ ├── FeedImageDataLoader.swift │ │ └── FeedImage.swift │ ├── Feed Cache │ │ ├── Infrastructure │ │ │ └── CoreData │ │ │ │ ├── FeedStore.xcdatamodeld │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── FeedStore.xcdatamodel │ │ │ │ │ └── contents │ │ │ │ └── FeedStore2.xcdatamodel │ │ │ │ │ └── contents │ │ │ │ ├── CoreDataFeedStore+FeedImageDataLoader.swift │ │ │ │ ├── ManagedCache.swift │ │ │ │ ├── CoreDataHelpers.swift │ │ │ │ ├── CoreDataFeedStore+FeedStore.swift │ │ │ │ ├── ManagedFeedImage.swift │ │ │ │ └── CoreDataFeedStore.swift │ │ ├── FeedImageDataStore.swift │ │ ├── LocalFeedImage.swift │ │ ├── FeedCachePolicy.swift │ │ ├── FeedStore.swift │ │ ├── LocalFeedImageDataLoader.swift │ │ └── LocalFeedLoader.swift │ ├── Shared API │ │ ├── HTTPClient.swift │ │ └── Shared API Infra │ │ │ └── URLSessionHTTPClient.swift │ ├── Image Commnets Feature │ │ └── ImageComment.swift │ └── Info.plist ├── EssentialFeediOS │ ├── Feed UI │ │ ├── Views │ │ │ ├── Feed.xcassets │ │ │ │ ├── Contents.json │ │ │ │ └── pin.imageset │ │ │ │ │ ├── pin.png │ │ │ │ │ ├── pin@2x.png │ │ │ │ │ ├── pin@3x.png │ │ │ │ │ └── Contents.json │ │ │ ├── Helpers │ │ │ │ ├── UIRefreshControl+Helpers.swift │ │ │ │ ├── UITableView+Dequeueing.swift │ │ │ │ ├── UIImageView+Animations.swift │ │ │ │ ├── UITableView+HeaderSizing.swift │ │ │ │ └── UIView+Shimmering.swift │ │ │ ├── FeedImageCell.swift │ │ │ └── ErrorView.swift │ │ └── Controllers │ │ │ ├── FeedImageCellController.swift │ │ │ └── FeedViewController.swift │ └── Info.plist ├── EssentialFeediOSTests │ ├── snapshot │ │ ├── EMPTY_FEED_dark.png │ │ ├── EMPTY_FEED_light.png │ │ ├── FEED_WITH_CONTENT_dark.png │ │ ├── FEED_WITH_CONTENT_light.png │ │ ├── FEED_WITH_ERROR_MESSAGE_dark.png │ │ ├── FEED_WITH_ERROR_MESSAGE_light.png │ │ ├── FEED_WITH_FAILED_IMAGE_LOADING_dark.png │ │ └── FEED_WITH_FAILED_IMAGE_LOADING_light.png │ ├── Helpers │ │ └── UIImage+TestHelpers.swift │ └── Info.plist ├── EssentialFeed.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── EssentialFeedAPIEndToEndTests.xcscheme │ │ ├── EssentialFeedCacheIntegrationTests.xcscheme │ │ ├── EssentialFeed.xcscheme │ │ └── EssentialFeediOS.xcscheme ├── EssentialFeedTests │ ├── Helpers │ │ ├── XCTestCase+MemoryLeakTracking.swift │ │ ├── SharedTestHelpers.swift │ │ └── SharedLocalizationTestHelpers.swift │ ├── Feed Presentation │ │ ├── FeedLocalizationTests.swift │ │ ├── FeedImagePresenterTests.swift │ │ └── FeedPresenterTests.swift │ ├── Image Comments Presentation │ │ ├── ImageCommentsLocalizationTests.swift │ │ └── ImageCommentsPresenterTests.swift │ ├── Feed Cache │ │ ├── FeedStoreSpecs │ │ │ ├── XCTestCase+FailableRetrieveFeedStoreSpecs.swift │ │ │ ├── XCTestCase+FailableDeleteFeedStoreSpecs.swift │ │ │ ├── XCTestCase+FailableInsertFeedStoreSpecs.swift │ │ │ └── FeedStoreSpecs.swift │ │ ├── Helpers │ │ │ ├── FeedCacheTestHelpers.swift │ │ │ ├── FeedImageDataStoreSpy.swift │ │ │ └── FeedStoreSpy.swift │ │ ├── CoreDataFeedStoreTests.swift │ │ ├── CacheFeedImageDataUseCaseTests.swift │ │ ├── CacheFeedUseCaseTests.swift │ │ ├── CoreDataFeedImageDataStoreTests.swift │ │ └── LoadFeedImageDataFromCacheUseCaseTests.swift │ ├── Shared Presentation │ │ ├── SharedLocalizationTests.swift │ │ └── LoadResourcePresenterTests.swift │ ├── Info.plist │ ├── Shared API │ │ └── Helpers │ │ │ └── HTTPClientSpy.swift │ ├── Shared APU Infra │ │ └── Helpers │ │ │ └── URLProtocolStub.swift │ ├── Feed API │ │ ├── FeedItemsMapperTests.swift │ │ └── LoadFeedFromRemoteUseCaseTests.swift │ └── Image Comments API │ │ └── ImageCommentsMapperTests.swift ├── EssentialFeedAPIEndToEndTests │ └── Info.plist └── EssentialFeedCacheIntegrationTests │ └── Info.plist ├── feed_flowchart.png ├── feed_architecture.png ├── Prototype ├── Prototype │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── pin.imageset │ │ │ ├── pin.png │ │ │ ├── pin@2x.png │ │ │ ├── pin@3x.png │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Icon-20.png │ │ │ ├── Icon-29.png │ │ │ ├── Icon-40.png │ │ │ ├── Icon-41.png │ │ │ ├── Icon-42.png │ │ │ ├── Icon-58.png │ │ │ ├── Icon-59.png │ │ │ ├── Icon-60.png │ │ │ ├── Icon-76.png │ │ │ ├── Icon-80.png │ │ │ ├── Icon-81.png │ │ │ ├── Icon-87.png │ │ │ ├── Icon-1024.png │ │ │ ├── Icon-120.png │ │ │ ├── Icon-121.png │ │ │ ├── Icon-152.png │ │ │ ├── Icon-167.png │ │ │ ├── Icon-180.png │ │ │ └── Contents.json │ │ ├── image-0.imageset │ │ │ ├── image-0.jpg │ │ │ └── Contents.json │ │ ├── image-1.imageset │ │ │ ├── image-1.jpg │ │ │ └── Contents.json │ │ ├── image-2.imageset │ │ │ ├── image-2.jpg │ │ │ └── Contents.json │ │ ├── image-3.imageset │ │ │ ├── image-3.jpg │ │ │ └── Contents.json │ │ ├── image-4.imageset │ │ │ ├── image-4.jpg │ │ │ └── Contents.json │ │ └── image-5.imageset │ │ │ ├── image-5.jpg │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Info.plist │ ├── FeedImageViewModel+PrototypeData.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── FeedViewController.swift │ └── FeedImageCell.swift └── Prototype.xcodeproj │ └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── EssentialApp ├── EssentialApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── WeakRefVirtualProxy.swift │ ├── LoadResourcePresentationAdapter.swift │ ├── FeedViewAdapter.swift │ ├── FeedUIComposer.swift │ ├── Info.plist │ ├── SceneDelegate.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ └── CombineHelpers.swift ├── EssentialApp.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── EssentialAppTests │ ├── Helpers │ │ ├── UIButton+TestHelpers.swift │ │ ├── UIRefreshControl+TestHelpers.swift │ │ ├── UIControl+TestHelpers.swift │ │ ├── UIView+TestHelpers.swift │ │ ├── XCTestCase+MemoryLeakTracking.swift │ │ ├── FeedUIIntegrationTests+Localization.swift │ │ ├── SharedTestHelpers.swift │ │ ├── UIImage+TestHelpers.swift │ │ ├── FeedImageCell+TestHelpers.swift │ │ ├── HTTPClientStub.swift │ │ ├── FeedImageDataLoaderSpy.swift │ │ ├── XCTestCase+FeedImageDataLoader.swift │ │ ├── FeedUIIntegrationTests+Assertions.swift │ │ ├── InMemoryFeedStore.swift │ │ ├── FeedUIIntegrationTests+LoaderSpy.swift │ │ └── FeedViewController+TestHelpers.swift │ ├── Info.plist │ ├── SceneDelegateTests.swift │ └── FeedAcceptanceTests.swift └── EssentialApp.xcworkspace │ ├── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── xcschemes │ │ └── EssentialApp.xcscheme │ └── contents.xcworkspacedata ├── .travis.yml ├── LICENSE.md ├── feed_architecture.xml └── .gitignore /EssentialFeed/EssentialFeed/Feed Presentation/en.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "My Feed"; 3 | -------------------------------------------------------------------------------- /feed_flowchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/feed_flowchart.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/el.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "Το Feed μου"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/pt-BR.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "Meu Feed"; 3 | -------------------------------------------------------------------------------- /feed_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/feed_architecture.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/en.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "My Feed"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/el.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "Το Feed μου"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/pt-BR.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "Meu feed"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/en.lproj/Shared.strings: -------------------------------------------------------------------------------- 1 | 2 | "GENERIC_CONNECTION_ERROR" = "Couldn't connect to server"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/el.lproj/Shared.strings: -------------------------------------------------------------------------------- 1 | 2 | "GENERIC_CONNECTION_ERROR" = "Δεν ήταν δυνατή η σύνδεση στο διακομιστή"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/pt-BR.lproj/Shared.strings: -------------------------------------------------------------------------------- 1 | 2 | "GENERIC_CONNECTION_ERROR" = "Não foi possível conectar com o servidor"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/RemoteFeedLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2018 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/EMPTY_FEED_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/EMPTY_FEED_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/EMPTY_FEED_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/EMPTY_FEED_light.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_CONTENT_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_CONTENT_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_CONTENT_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_CONTENT_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | public struct FeedViewModel { 6 | public let feed: [FeedImage] 7 | } 8 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_ERROR_MESSAGE_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_ERROR_MESSAGE_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_ERROR_MESSAGE_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_ERROR_MESSAGE_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | @UIApplicationMain 8 | class AppDelegate: UIResponder, UIApplicationDelegate { } 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_FAILED_IMAGE_LOADING_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_FAILED_IMAGE_LOADING_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_FAILED_IMAGE_LOADING_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TulioOParreiras/ios-lead-essentials/HEAD/EssentialFeed/EssentialFeediOSTests/snapshot/FEED_WITH_FAILED_IMAGE_LOADING_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | public struct ResourceLoadingViewModel { 6 | public let isLoading: Bool 7 | } 8 | -------------------------------------------------------------------------------- /Prototype/Prototype.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIButton { 8 | func simulateTap() { 9 | simulate(event: .touchUpInside) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIRefreshControl { 8 | func simulatePullToRefresh() { 9 | simulate(event: .valueChanged) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/ImageComments API/RemoteImageCommentsLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageCommentsLoader.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 27/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIRefreshControl+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIRefreshControl { 8 | func update(isRefreshing: Bool) { 9 | isRefreshing ? beginRefreshing() : endRefreshing() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedCache { 8 | typealias Result = Swift.Result 9 | 10 | func save(_ feed: [FeedImage], completion: @escaping (Result) -> Void) 11 | } 12 | -------------------------------------------------------------------------------- /Prototype/Prototype.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | public struct FeedImageViewModel { 6 | public let description: String? 7 | public let location: String? 8 | 9 | public var hasLocation: Bool { 10 | return location != nil 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/Helpers/HTTPURLResponse+StatusCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension HTTPURLResponse { 8 | private static var OK_200: Int { return 200 } 9 | 10 | var isOK: Bool { 11 | return statusCode == HTTPURLResponse.OK_200 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataCache { 8 | typealias Result = Swift.Result 9 | 10 | func save(_ data: Data, for url: URL, completion: @escaping (Result) -> Void) 11 | } 12 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | FeedStore2.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceErrorView.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ResourceErrorView { 12 | func display(_ viewModel: ResourceErrorViewModel) 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+Dequeueing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UITableView { 8 | func dequeueReusableCell() -> T { 9 | let identifier = String(describing: T.self) 10 | return dequeueReusableCell(withIdentifier: identifier) as! T 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceLoadingView.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ResourceLoadingView { 12 | func display(_ viewModel: ResourceLoadingViewModel) 13 | } 14 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIControl+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIControl { 8 | func simulate(event: UIControl.Event) { 9 | allTargets.forEach { target in 10 | actions(forTarget: target, forControlEvent: event)?.forEach { 11 | (target as NSObject).perform(Selector($0)) 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIView+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+TestHelpers.swift 3 | // EssentialAppTests 4 | // 5 | // Created by Usemobile on 13/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIView { 12 | func enforceLayoutCycle() { 13 | layoutIfNeeded() 14 | RunLoop.current.run(until: Date()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Prototype/Prototype/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | @UIApplicationMain 8 | class AppDelegate: UIResponder, UIApplicationDelegate { 9 | var window: UIWindow? 10 | 11 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 12 | return true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class FeedImagePresenter { 8 | public static func map(_ image: FeedImage) -> FeedImageViewModel { 9 | FeedImageViewModel( 10 | description: image.description, 11 | location: image.location) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIImageView+Animations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIImageView { 8 | func setImageAnimated(_ newImage: UIImage?) { 9 | image = newImage 10 | 11 | guard newImage != nil else { return } 12 | 13 | alpha = 0 14 | UIView.animate(withDuration: 0.25) { 15 | self.alpha = 1 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-0.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-0.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-1.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-2.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-3.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-4.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image-5.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataLoaderTask { 8 | func cancel() 9 | } 10 | 11 | public protocol FeedImageDataLoader { 12 | typealias Result = Swift.Result 13 | 14 | func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask 15 | } 16 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/ResourceErrorViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | public struct ResourceErrorViewModel { 6 | public let message: String? 7 | 8 | static var noError: ResourceErrorViewModel { 9 | return ResourceErrorViewModel(message: nil) 10 | } 11 | 12 | static func error(message: String) -> ResourceErrorViewModel { 13 | return ResourceErrorViewModel(message: message) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | extension XCTestCase { 8 | func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #file, line: UInt = #line) { 9 | addTeardownBlock { [weak instance] in 10 | XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | extension XCTestCase { 8 | func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #file, line: UInt = #line) { 9 | addTeardownBlock { [weak instance] in 10 | XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | final class FeedLocalizationTests: XCTestCase { 9 | 10 | func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { 11 | let table = "Feed" 12 | let bundle = Bundle(for: FeedPresenter.self) 13 | 14 | assertLocalizedKeyAndValuesExist(in: bundle, table) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx 2 | osx_image: xcode11.2 3 | language: swift 4 | script: 5 | - xcodebuild clean build test -project EssentialFeed/EssentialFeed.xcodeproj -scheme "CI_macOS" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -sdk macosx -destination "platform=macOS" ONLY_ACTIVE_ARCH=YES 6 | - xcodebuild clean build test -workspace EssentialApp/EssentialApp.xcworkspace -scheme "CI_iOS" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -sdk iphonesimulator -destination "platform=iOS Simulator,OS=13.2,name=iPhone 11" ONLY_ACTIVE_ARCH=YES -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "pin.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "pin@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "pin@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/FeedImageDataStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataStore { 8 | typealias RetrievalResult = Swift.Result 9 | typealias InsertionResult = Swift.Result 10 | 11 | func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) 12 | func retrieve(dataForURL url: URL, completion: @escaping (RetrievalResult) -> Void) 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2018 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct FeedImage: Hashable { 8 | public let id: UUID 9 | public let description: String? 10 | public let location: String? 11 | public let url: URL 12 | 13 | public init(id: UUID, description: String?, location: String?, url: URL) { 14 | self.id = id 15 | self.description = description 16 | self.location = location 17 | self.url = url 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "pin.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "pin@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "pin@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct LocalFeedImage: Equatable { 8 | public let id: UUID 9 | public let description: String? 10 | public let location: String? 11 | public let url: URL 12 | 13 | public init(id: UUID, description: String?, location: String?, url: URL) { 14 | self.id = id 15 | self.description = description 16 | self.location = location 17 | self.url = url 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class FeedPresenter { 8 | public static var title: String { 9 | return NSLocalizedString("FEED_VIEW_TITLE", 10 | tableName: "Feed", 11 | bundle: Bundle(for: FeedPresenter.self), 12 | comment: "Title for the feed view") 13 | } 14 | 15 | public static func map(_ feed: [FeedImage]) -> FeedViewModel { 16 | FeedViewModel(feed: feed) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import XCTest 7 | import EssentialFeed 8 | 9 | extension FeedUIIntegrationTests { 10 | private class DummyView: ResourceView { 11 | func display(_ viewModel: Any) { } 12 | } 13 | 14 | var loadError: String { 15 | LoadResourcePresenter.loadError 16 | } 17 | 18 | var feedTitle: String { 19 | FeedPresenter.title 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | func anyNSError() -> NSError { 9 | return NSError(domain: "any error", code: 0) 10 | } 11 | 12 | func anyURL() -> URL { 13 | return URL(string: "http://any-url.com")! 14 | } 15 | 16 | func anyData() -> Data { 17 | return Data("any data".utf8) 18 | } 19 | 20 | func uniqueFeed() -> [FeedImage] { 21 | return [FeedImage(id: UUID(), description: "any", location: "any", url: anyURL())] 22 | } 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedImagePresenterTests: XCTestCase { 9 | 10 | func test_map_createsViewModel() { 11 | let image = uniqueImage() 12 | 13 | let viewModel = FeedImagePresenter.map(image) 14 | 15 | XCTAssertEqual(viewModel.description, image.description) 16 | XCTAssertEqual(viewModel.location, image.location) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIImage+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIImage { 8 | static func make(withColor color: UIColor) -> UIImage { 9 | let rect = CGRect(x: 0, y: 0, width: 1, height: 1) 10 | UIGraphicsBeginImageContext(rect.size) 11 | let context = UIGraphicsGetCurrentContext()! 12 | context.setFillColor(color.cgColor) 13 | context.fill(rect) 14 | let img = UIGraphicsGetImageFromCurrentImageContext() 15 | UIGraphicsEndImageContext() 16 | return img! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol HTTPClientTask { 8 | func cancel() 9 | } 10 | 11 | public protocol HTTPClient { 12 | typealias Result = Swift.Result<(Data, HTTPURLResponse), Error> 13 | 14 | /// The completion handler can be invoked in any thread. 15 | /// Clients are responsible to dispatch to appropriate threads, if needed. 16 | @discardableResult 17 | func get(from url: URL, completion: @escaping (Result) -> Void) -> HTTPClientTask 18 | } 19 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Helpers/UIImage+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIImage { 8 | static func make(withColor color: UIColor) -> UIImage { 9 | let rect = CGRect(x: 0, y: 0, width: 1, height: 1) 10 | UIGraphicsBeginImageContext(rect.size) 11 | let context = UIGraphicsGetCurrentContext()! 12 | context.setFillColor(color.cgColor) 13 | context.fill(rect) 14 | let img = UIGraphicsGetImageFromCurrentImageContext() 15 | UIGraphicsEndImageContext() 16 | return img! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | final class FeedCachePolicy { 8 | private init() {} 9 | 10 | private static let calendar = Calendar(identifier: .gregorian) 11 | 12 | private static var maxCacheAgeInDays: Int { 13 | return 7 14 | } 15 | 16 | static func validate(_ timestamp: Date, against date: Date) -> Bool { 17 | guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { 18 | return false 19 | } 20 | return date < maxCacheAge 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCommentsLocalizationTests.swift 3 | // EssentialFeedTests 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeed 11 | 12 | class ImageCommentsLocalizationTests: XCTestCase { 13 | 14 | func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { 15 | let table = "ImageComments" 16 | let bundle = Bundle(for: ImageCommentsPresenter.self) 17 | 18 | assertLocalizedKeyAndValuesExist(in: bundle, table) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Commnets Feature/ImageComment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageComment.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 27/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ImageComment: Equatable { 12 | public let id: UUID 13 | public let message: String 14 | public let createdAt: Date 15 | public let username: String 16 | 17 | public init(id: UUID, message: String, createdAt: Date, username: String) { 18 | self.id = id 19 | self.message = message 20 | self.createdAt = createdAt 21 | self.username = username 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableRetrieveFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | extension FailableRetrieveFeedStoreSpecs where Self: XCTestCase { 9 | func assertThatRetrieveDeliversFailureOnRetrievalError(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 10 | expect(sut, toRetrieve: .failure(anyNSError()), file: file, line: line) 11 | } 12 | 13 | func assertThatRetrieveHasNoSideEffectsOnFailure(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 14 | expect(sut, toRetrieveTwice: .failure(anyNSError()), file: file, line: line) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/FeedImageCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public final class FeedImageCell: UITableViewCell { 8 | @IBOutlet private(set) public var locationContainer: UIView! 9 | @IBOutlet private(set) public var locationLabel: UILabel! 10 | @IBOutlet private(set) public var feedImageContainer: UIView! 11 | @IBOutlet private(set) public var feedImageView: UIImageView! 12 | @IBOutlet private(set) public var feedImageRetryButton: UIButton! 13 | @IBOutlet private(set) public var descriptionLabel: UILabel! 14 | 15 | var onRetry: (() -> Void)? 16 | 17 | @IBAction private func retryButtonTapped() { 18 | onRetry?() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UITableView+HeaderSizing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+HeaderSizing.swift 3 | // EssentialFeediOS 4 | // 5 | // Created by Usemobile on 12/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITableView { 12 | func sizeTableHeaderToFit() { 13 | guard let header = tableHeaderView else { return } 14 | 15 | let size = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) 16 | 17 | let needsFrameUpdate = header.frame.height != size.height 18 | if needsFrameUpdate { 19 | header.frame.size.height = size.height 20 | tableHeaderView = header 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharedLocalizationTests.swift 3 | // EssentialFeedTests 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeed 11 | 12 | class SharedLocalizationTests: XCTestCase { 13 | 14 | func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { 15 | let table = "Shared" 16 | let bundle = Bundle(for: LoadResourcePresenter.self) 17 | 18 | assertLocalizedKeyAndValuesExist(in: bundle, table) 19 | } 20 | 21 | private class DummyView: ResourceView { 22 | func display(_ viewModel: Any) { } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableDeleteFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | extension FailableDeleteFeedStoreSpecs where Self: XCTestCase { 9 | func assertThatDeleteDeliversErrorOnDeletionError(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 10 | let deletionError = deleteCache(from: sut) 11 | 12 | XCTAssertNotNil(deletionError, "Expected cache deletion to fail", file: file, line: line) 13 | } 14 | 15 | func assertThatDeleteHasNoSideEffectsOnDeletionError(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 16 | deleteCache(from: sut) 17 | 18 | expect(sut, toRetrieve: .success(.none), file: file, line: line) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedAPIEndToEndTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedCacheIntegrationTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | func uniqueImage() -> FeedImage { 9 | return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) 10 | } 11 | 12 | func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { 13 | let models = [uniqueImage(), uniqueImage()] 14 | let local = models.map { LocalFeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.url) } 15 | return (models, local) 16 | } 17 | 18 | extension Date { 19 | func minusFeedCacheMaxAge() -> Date { 20 | return adding(days: -feedCacheMaxAgeInDays) 21 | } 22 | 23 | private var feedCacheMaxAgeInDays: Int { 24 | return 7 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeediOS 7 | 8 | extension FeedImageCell { 9 | func simulateRetryAction() { 10 | feedImageRetryButton.simulateTap() 11 | } 12 | 13 | var isShowingLocation: Bool { 14 | return !locationContainer.isHidden 15 | } 16 | 17 | var isShowingImageLoadingIndicator: Bool { 18 | return feedImageContainer.isShimmering 19 | } 20 | 21 | var isShowingRetryAction: Bool { 22 | return !feedImageRetryButton.isHidden 23 | } 24 | 25 | var locationText: String? { 26 | return locationLabel.text 27 | } 28 | 29 | var descriptionText: String? { 30 | return descriptionLabel.text 31 | } 32 | 33 | var renderedImage: Data? { 34 | return feedImageView.image?.pngData() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension CoreDataFeedStore: FeedImageDataStore { 8 | 9 | public func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { 10 | perform { context in 11 | completion(Result { 12 | try ManagedFeedImage.first(with: url, in: context) 13 | .map { $0.data = data } 14 | .map(context.save) 15 | }) 16 | } 17 | } 18 | 19 | public func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { 20 | perform { context in 21 | completion(Result { 22 | try ManagedFeedImage.first(with: url, in: context)?.data 23 | }) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableInsertFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | extension FailableInsertFeedStoreSpecs where Self: XCTestCase { 9 | func assertThatInsertDeliversErrorOnInsertionError(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 10 | let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) 11 | 12 | XCTAssertNotNil(insertionError, "Expected cache insertion to fail with an error", file: file, line: line) 13 | } 14 | 15 | func assertThatInsertHasNoSideEffectsOnInsertionError(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { 16 | insert((uniqueImageFeed().local, Date()), to: sut) 17 | 18 | expect(sut, toRetrieve: .success(.none), file: file, line: line) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/WeakRefVirtualProxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | 8 | final class WeakRefVirtualProxy { 9 | private weak var object: T? 10 | 11 | init(_ object: T) { 12 | self.object = object 13 | } 14 | } 15 | 16 | extension WeakRefVirtualProxy: ResourceErrorView where T: ResourceErrorView { 17 | func display(_ viewModel: ResourceErrorViewModel) { 18 | object?.display(viewModel) 19 | } 20 | } 21 | 22 | extension WeakRefVirtualProxy: ResourceLoadingView where T: ResourceLoadingView { 23 | func display(_ viewModel: ResourceLoadingViewModel) { 24 | object?.display(viewModel) 25 | } 26 | } 27 | 28 | extension WeakRefVirtualProxy: ResourceView where T: ResourceView, T.ResourceViewModel == UIImage { 29 | func display(_ viewModel: UIImage) { 30 | object?.display(viewModel) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | @objc(ManagedCache) 8 | class ManagedCache: NSManagedObject { 9 | @NSManaged var timestamp: Date 10 | @NSManaged var feed: NSOrderedSet 11 | } 12 | 13 | extension ManagedCache { 14 | static func find(in context: NSManagedObjectContext) throws -> ManagedCache? { 15 | let request = NSFetchRequest(entityName: entity().name!) 16 | request.returnsObjectsAsFaults = false 17 | return try context.fetch(request).first 18 | } 19 | 20 | static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { 21 | try find(in: context).map(context.delete) 22 | return ManagedCache(context: context) 23 | } 24 | 25 | var localFeed: [LocalFeedImage] { 26 | return feed.compactMap { ($0 as? ManagedFeedImage)?.local } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSHumanReadableCopyright 22 | Copyright © 2018 Essential Developer. All rights reserved. 23 | 24 | 25 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | extension NSPersistentContainer { 8 | static func load(name: String, model: NSManagedObjectModel, url: URL) throws -> NSPersistentContainer { 9 | let description = NSPersistentStoreDescription(url: url) 10 | let container = NSPersistentContainer(name: name, managedObjectModel: model) 11 | container.persistentStoreDescriptions = [description] 12 | 13 | var loadError: Swift.Error? 14 | container.loadPersistentStores { loadError = $1 } 15 | try loadError.map { throw $0 } 16 | 17 | return container 18 | } 19 | } 20 | 21 | extension NSManagedObjectModel { 22 | static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { 23 | return bundle 24 | .url(forResource: name, withExtension: "momd") 25 | .flatMap { NSManagedObjectModel(contentsOf: $0) } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class FeedItemsMapper { 8 | private struct Root: Decodable { 9 | private let items: [RemoteFeedItem] 10 | 11 | private struct RemoteFeedItem: Decodable { 12 | let id: UUID 13 | let description: String? 14 | let location: String? 15 | let image: URL 16 | } 17 | 18 | var images: [FeedImage] { 19 | items.map { FeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.image) } 20 | } 21 | } 22 | 23 | public enum Error: Swift.Error { 24 | case invalidData 25 | } 26 | 27 | public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [FeedImage] { 28 | guard response.isOK, let root = try? JSONDecoder().decode(Root.self, from: data) else { 29 | throw Error.invalidData 30 | } 31 | 32 | return root.images 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedPresenterTests: XCTestCase { 9 | 10 | func test_title_isLocalized() { 11 | XCTAssertEqual(FeedPresenter.title, localized("FEED_VIEW_TITLE")) 12 | } 13 | 14 | func test_map_createsViewModels() { 15 | let feed = uniqueImageFeed().models 16 | 17 | let viewModels = FeedPresenter.map(feed) 18 | 19 | XCTAssertEqual(viewModels.feed, feed) 20 | } 21 | 22 | // MARK: - Helpers 23 | 24 | private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { 25 | let table = "Feed" 26 | let bundle = Bundle(for: FeedPresenter.self) 27 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 28 | if value == key { 29 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 30 | } 31 | return value 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPClientStub.swift 3 | // EssentialAppTests 4 | // 5 | // Created by Usemobile on 03/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import EssentialFeed 11 | 12 | class HTTPClientStub: HTTPClient { 13 | private class Task: HTTPClientTask { 14 | func cancel() { } 15 | } 16 | 17 | private let stub: (URL) -> HTTPClient.Result 18 | 19 | init(stub: @escaping (URL) -> HTTPClient.Result) { 20 | self.stub = stub 21 | } 22 | 23 | func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { 24 | completion(stub(url)) 25 | return Task() 26 | } 27 | 28 | static var offline: HTTPClientStub { 29 | HTTPClientStub(stub: { _ in .failure(NSError(domain: "offline", code: 0))}) 30 | } 31 | 32 | static func online(_ stub: @escaping (URL) -> (Data, HTTPURLResponse)) -> HTTPClientStub { 33 | HTTPClientStub { url in .success(stub(url)) } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedImageDataLoaderSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | class FeedImageDataLoaderSpy: FeedImageDataLoader { 9 | private var messages = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]() 10 | 11 | private(set) var cancelledURLs = [URL]() 12 | 13 | var loadedURLs: [URL] { 14 | return messages.map { $0.url } 15 | } 16 | 17 | private struct Task: FeedImageDataLoaderTask { 18 | let callback: () -> Void 19 | func cancel() { callback() } 20 | } 21 | 22 | func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { 23 | messages.append((url, completion)) 24 | return Task { [weak self] in 25 | self?.cancelledURLs.append(url) 26 | } 27 | } 28 | 29 | func complete(with error: Error, at index: Int = 0) { 30 | messages[index].completion(.failure(error)) 31 | } 32 | 33 | func complete(with data: Data, at index: Int = 0) { 34 | messages[index].completion(.success(data)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/XCTestCase+FeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | protocol FeedImageDataLoaderTestCase: XCTestCase {} 9 | 10 | extension FeedImageDataLoaderTestCase { 11 | func expect(_ sut: FeedImageDataLoader, toCompleteWith expectedResult: FeedImageDataLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { 12 | let exp = expectation(description: "Wait for load completion") 13 | 14 | _ = sut.loadImageData(from: anyURL()) { receivedResult in 15 | switch (receivedResult, expectedResult) { 16 | case let (.success(receivedFeed), .success(expectedFeed)): 17 | XCTAssertEqual(receivedFeed, expectedFeed, file: file, line: line) 18 | 19 | case (.failure, .failure): 20 | break 21 | 22 | default: 23 | XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) 24 | } 25 | 26 | exp.fulfill() 27 | } 28 | 29 | action() 30 | 31 | wait(for: [exp], timeout: 1.0) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Essential Developer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared API/Shared API Infra/URLSessionHTTPClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class URLSessionHTTPClient: HTTPClient { 8 | private let session: URLSession 9 | 10 | public init(session: URLSession) { 11 | self.session = session 12 | } 13 | 14 | private struct UnexpectedValuesRepresentation: Error {} 15 | 16 | private struct URLSessionTaskWrapper: HTTPClientTask { 17 | let wrapped: URLSessionTask 18 | 19 | func cancel() { 20 | wrapped.cancel() 21 | } 22 | } 23 | 24 | public func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { 25 | let task = session.dataTask(with: url) { data, response, error in 26 | completion(Result { 27 | if let error = error { 28 | throw error 29 | } else if let data = data, let response = response as? HTTPURLResponse { 30 | return (data, response) 31 | } else { 32 | throw UnexpectedValuesRepresentation() 33 | } 34 | }) 35 | } 36 | task.resume() 37 | return URLSessionTaskWrapper(wrapped: task) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | extension CoreDataFeedStore: FeedStore { 8 | 9 | public func retrieve(completion: @escaping RetrievalCompletion) { 10 | perform { context in 11 | completion(Result { 12 | try ManagedCache.find(in: context).map { 13 | CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) 14 | } 15 | }) 16 | } 17 | } 18 | 19 | public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { 20 | perform { context in 21 | completion(Result { 22 | let managedCache = try ManagedCache.newUniqueInstance(in: context) 23 | managedCache.timestamp = timestamp 24 | managedCache.feed = ManagedFeedImage.images(from: feed, in: context) 25 | try context.save() 26 | }) 27 | } 28 | } 29 | 30 | public func deleteCachedFeed(completion: @escaping DeletionCompletion) { 31 | perform { context in 32 | completion(Result { 33 | try ManagedCache.find(in: context).map(context.delete).map(context.save) 34 | }) 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public final class ErrorView: UIView { 8 | @IBOutlet private var label: UILabel! 9 | 10 | public var message: String? { 11 | get { return isVisible ? label.text : nil } 12 | set { setMessageAnimated(newValue) } 13 | } 14 | 15 | public override func awakeFromNib() { 16 | super.awakeFromNib() 17 | 18 | label.text = nil 19 | alpha = 0 20 | } 21 | 22 | private var isVisible: Bool { 23 | return alpha > 0 24 | } 25 | 26 | private func setMessageAnimated(_ message: String?) { 27 | if let message = message { 28 | showAnimated(message) 29 | } else { 30 | hideMessageAnimated() 31 | } 32 | } 33 | 34 | private func showAnimated(_ message: String) { 35 | label.text = message 36 | 37 | UIView.animate(withDuration: 0.25) { 38 | self.alpha = 1 39 | } 40 | } 41 | 42 | @IBAction private func hideMessageAnimated() { 43 | UIView.animate( 44 | withDuration: 0.25, 45 | animations: { self.alpha = 0 }, 46 | completion: { completed in 47 | if completed { self.label.text = nil } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared API/Helpers/HTTPClientSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | class HTTPClientSpy: HTTPClient { 9 | private struct Task: HTTPClientTask { 10 | let callback: () -> Void 11 | func cancel() { callback() } 12 | } 13 | 14 | private var messages = [(url: URL, completion: (HTTPClient.Result) -> Void)]() 15 | private(set) var cancelledURLs = [URL]() 16 | 17 | var requestedURLs: [URL] { 18 | return messages.map { $0.url } 19 | } 20 | 21 | func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { 22 | messages.append((url, completion)) 23 | return Task { [weak self] in 24 | self?.cancelledURLs.append(url) 25 | } 26 | } 27 | 28 | func complete(with error: Error, at index: Int = 0) { 29 | messages[index].completion(.failure(error)) 30 | } 31 | 32 | func complete(withStatusCode code: Int, data: Data, at index: Int = 0) { 33 | let response = HTTPURLResponse( 34 | url: requestedURLs[index], 35 | statusCode: code, 36 | httpVersion: nil, 37 | headerFields: nil 38 | )! 39 | messages[index].completion(.success((data, response))) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | func anyNSError() -> NSError { 8 | return NSError(domain: "any error", code: 0) 9 | } 10 | 11 | func anyURL() -> URL { 12 | return URL(string: "http://any-url.com")! 13 | } 14 | 15 | func anyData() -> Data { 16 | return Data("any data".utf8) 17 | } 18 | 19 | func makeItemsJSON(_ items: [[String: Any]]) -> Data { 20 | let json = ["items": items] 21 | return try! JSONSerialization.data(withJSONObject: json) 22 | } 23 | 24 | extension HTTPURLResponse { 25 | convenience init(statusCode: Int) { 26 | self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! 27 | } 28 | } 29 | 30 | extension Date { 31 | func adding(seconds: TimeInterval) -> Date { 32 | return self + seconds 33 | } 34 | 35 | func adding(minutes: Int, calendar: Calendar = Calendar(identifier: .gregorian)) -> Date { 36 | return calendar.date(byAdding: .minute, value: minutes, to: self)! 37 | } 38 | 39 | func adding(days: Int, calendar: Calendar = Calendar(identifier: .gregorian)) -> Date { 40 | return calendar.date(byAdding: .day, value: days, to: self)! 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public typealias CachedFeed = (feed: [LocalFeedImage], timestamp: Date) 8 | 9 | public protocol FeedStore { 10 | typealias DeletionResult = Result 11 | typealias DeletionCompletion = (DeletionResult) -> Void 12 | 13 | typealias InsertionResult = Result 14 | typealias InsertionCompletion = (InsertionResult) -> Void 15 | 16 | typealias RetrievalResult = Result 17 | typealias RetrievalCompletion = (RetrievalResult) -> Void 18 | 19 | /// The completion handler can be invoked in any thread. 20 | /// Clients are responsible to dispatch to appropriate threads, if needed. 21 | func deleteCachedFeed(completion: @escaping DeletionCompletion) 22 | 23 | /// The completion handler can be invoked in any thread. 24 | /// Clients are responsible to dispatch to appropriate threads, if needed. 25 | func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) 26 | 27 | /// The completion handler can be invoked in any thread. 28 | /// Clients are responsible to dispatch to appropriate threads, if needed. 29 | func retrieve(completion: @escaping RetrievalCompletion) 30 | } 31 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/SceneDelegateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegateTests.swift 3 | // EssentialAppTests 4 | // 5 | // Created by Usemobile on 03/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeediOS 11 | @testable import EssentialApp 12 | 13 | class SceneDelegateTests: XCTestCase { 14 | 15 | func test_configureWindow_setsWindowAsKeyAndVisible() { 16 | let window = UIWindow() 17 | let sut = SceneDelegate() 18 | sut.window = window 19 | 20 | sut.configureWindow() 21 | 22 | XCTAssertTrue(window.isKeyWindow, "Expected window to be key window") 23 | XCTAssertFalse(window.isHidden, "Expected window to be visible") 24 | } 25 | 26 | func test_configureWindow_configuresRootViewController() { 27 | let sut = SceneDelegate() 28 | sut.window = UIWindow() 29 | 30 | sut.configureWindow() 31 | 32 | let root = sut.window?.rootViewController 33 | let rootNavigation = root as? UINavigationController 34 | let topController = rootNavigation?.topViewController 35 | 36 | XCTAssertNotNil(rootNavigation, "Expected a navigation controller as root, got \(String(describing: root)) instead") 37 | XCTAssertTrue(topController is FeedViewController, "Expected a feed controller, got \(String(describing: topController)) instead") 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIView { 8 | public var isShimmering: Bool { 9 | set { 10 | if newValue { 11 | startShimmering() 12 | } else { 13 | stopShimmering() 14 | } 15 | } 16 | 17 | get { 18 | return layer.mask?.animation(forKey: shimmerAnimationKey) != nil 19 | } 20 | } 21 | 22 | private var shimmerAnimationKey: String { 23 | return "shimmer" 24 | } 25 | 26 | private func startShimmering() { 27 | let white = UIColor.white.cgColor 28 | let alpha = UIColor.white.withAlphaComponent(0.75).cgColor 29 | let width = bounds.width 30 | let height = bounds.height 31 | 32 | let gradient = CAGradientLayer() 33 | gradient.colors = [alpha, white, alpha] 34 | gradient.startPoint = CGPoint(x: 0.0, y: 0.4) 35 | gradient.endPoint = CGPoint(x: 1.0, y: 0.6) 36 | gradient.locations = [0.4, 0.5, 0.6] 37 | gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) 38 | layer.mask = gradient 39 | 40 | let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) 41 | animation.fromValue = [0.0, 0.1, 0.2] 42 | animation.toValue = [0.8, 0.9, 1.0] 43 | animation.duration = 1.25 44 | animation.repeatCount = .infinity 45 | gradient.add(animation, forKey: shimmerAnimationKey) 46 | } 47 | 48 | private func stopShimmering() { 49 | layer.mask = nil 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | @objc(ManagedFeedImage) 8 | class ManagedFeedImage: NSManagedObject { 9 | @NSManaged var id: UUID 10 | @NSManaged var imageDescription: String? 11 | @NSManaged var location: String? 12 | @NSManaged var url: URL 13 | @NSManaged var data: Data? 14 | @NSManaged var cache: ManagedCache 15 | } 16 | 17 | extension ManagedFeedImage { 18 | static func first(with url: URL, in context: NSManagedObjectContext) throws -> ManagedFeedImage? { 19 | let request = NSFetchRequest(entityName: entity().name!) 20 | request.predicate = NSPredicate(format: "%K = %@", argumentArray: [#keyPath(ManagedFeedImage.url), url]) 21 | request.returnsObjectsAsFaults = false 22 | request.fetchLimit = 1 23 | return try context.fetch(request).first 24 | } 25 | 26 | static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { 27 | return NSOrderedSet(array: localFeed.map { local in 28 | let managed = ManagedFeedImage(context: context) 29 | managed.id = local.id 30 | managed.imageDescription = local.description 31 | managed.location = local.location 32 | managed.url = local.url 33 | return managed 34 | }) 35 | } 36 | 37 | var local: LocalFeedImage { 38 | return LocalFeedImage(id: id, description: imageDescription, location: location, url: url) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | public final class CoreDataFeedStore { 8 | private static let modelName = "FeedStore" 9 | private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) 10 | 11 | private let container: NSPersistentContainer 12 | private let context: NSManagedObjectContext 13 | 14 | enum StoreError: Error { 15 | case modelNotFound 16 | case failedToLoadPersistentContainer(Error) 17 | } 18 | 19 | public init(storeURL: URL) throws { 20 | guard let model = CoreDataFeedStore.model else { 21 | throw StoreError.modelNotFound 22 | } 23 | 24 | do { 25 | container = try NSPersistentContainer.load(name: CoreDataFeedStore.modelName, model: model, url: storeURL) 26 | context = container.newBackgroundContext() 27 | } catch { 28 | throw StoreError.failedToLoadPersistentContainer(error) 29 | } 30 | } 31 | 32 | func perform(_ action: @escaping (NSManagedObjectContext) -> Void) { 33 | let context = self.context 34 | context.perform { action(context) } 35 | } 36 | 37 | private func cleanUpReferencesToPersistentStores() { 38 | context.performAndWait { 39 | let coordinator = self.container.persistentStoreCoordinator 40 | try? coordinator.persistentStores.forEach(coordinator.remove) 41 | } 42 | } 43 | 44 | deinit { 45 | cleanUpReferencesToPersistentStores() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Prototype/Prototype/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol FeedStoreSpecs { 8 | func test_retrieve_deliversEmptyOnEmptyCache() 9 | func test_retrieve_hasNoSideEffectsOnEmptyCache() 10 | func test_retrieve_deliversFoundValuesOnNonEmptyCache() 11 | func test_retrieve_hasNoSideEffectsOnNonEmptyCache() 12 | 13 | func test_insert_deliversNoErrorOnEmptyCache() 14 | func test_insert_deliversNoErrorOnNonEmptyCache() 15 | func test_insert_overridesPreviouslyInsertedCacheValues() 16 | 17 | func test_delete_deliversNoErrorOnEmptyCache() 18 | func test_delete_hasNoSideEffectsOnEmptyCache() 19 | func test_delete_deliversNoErrorOnNonEmptyCache() 20 | func test_delete_emptiesPreviouslyInsertedCache() 21 | 22 | func test_storeSideEffects_runSerially() 23 | } 24 | 25 | protocol FailableRetrieveFeedStoreSpecs: FeedStoreSpecs { 26 | func test_retrieve_deliversFailureOnRetrievalError() 27 | func test_retrieve_hasNoSideEffectsOnFailure() 28 | } 29 | 30 | protocol FailableInsertFeedStoreSpecs: FeedStoreSpecs { 31 | func test_insert_deliversErrorOnInsertionError() 32 | func test_insert_hasNoSideEffectsOnInsertionError() 33 | } 34 | 35 | protocol FailableDeleteFeedStoreSpecs: FeedStoreSpecs { 36 | func test_delete_deliversErrorOnDeletionError() 37 | func test_delete_hasNoSideEffectsOnDeletionError() 38 | } 39 | 40 | typealias FailableFeedStoreSpecs = FailableRetrieveFeedStoreSpecs & FailableInsertFeedStoreSpecs & FailableDeleteFeedStoreSpecs 41 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import EssentialFeed 6 | import EssentialFeediOS 7 | import Combine 8 | 9 | final class LoadResourcePresentationAdapter { 10 | private let loader: () -> AnyPublisher 11 | var presenter: LoadResourcePresenter? 12 | var cancellable: AnyCancellable? 13 | 14 | init(loader: @escaping () -> AnyPublisher) { 15 | self.loader = loader 16 | } 17 | 18 | func loadResource() { 19 | presenter?.didStartLoading() 20 | 21 | cancellable = loader() 22 | .dispatchOnMainThread() 23 | .sink(receiveCompletion: { [weak self] completion in 24 | switch completion { 25 | case .finished: break 26 | case let .failure(error): 27 | self?.presenter?.didFinishLoading(with: error) 28 | } 29 | }) { [weak self] resource in 30 | self?.presenter?.didFinishLoading(with: resource) 31 | } 32 | 33 | } 34 | } 35 | 36 | extension LoadResourcePresentationAdapter: FeedViewControllerDelegate { 37 | func didRequestFeedRefresh() { 38 | loadResource() 39 | } 40 | } 41 | 42 | extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { 43 | func didRequestImage() { 44 | loadResource() 45 | } 46 | 47 | func didCancelImageRequest() { 48 | cancellable?.cancel() 49 | cancellable = nil 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | class FeedImageDataStoreSpy: FeedImageDataStore { 9 | enum Message: Equatable { 10 | case insert(data: Data, for: URL) 11 | case retrieve(dataFor: URL) 12 | } 13 | 14 | private(set) var receivedMessages = [Message]() 15 | private var retrievalCompletions = [(FeedImageDataStore.RetrievalResult) -> Void]() 16 | private var insertionCompletions = [(FeedImageDataStore.InsertionResult) -> Void]() 17 | 18 | func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { 19 | receivedMessages.append(.insert(data: data, for: url)) 20 | insertionCompletions.append(completion) 21 | } 22 | 23 | func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { 24 | receivedMessages.append(.retrieve(dataFor: url)) 25 | retrievalCompletions.append(completion) 26 | } 27 | 28 | func completeRetrieval(with error: Error, at index: Int = 0) { 29 | retrievalCompletions[index](.failure(error)) 30 | } 31 | 32 | func completeRetrieval(with data: Data?, at index: Int = 0) { 33 | retrievalCompletions[index](.success(data)) 34 | } 35 | 36 | func completeInsertion(with error: Error, at index: Int = 0) { 37 | insertionCompletions[index](.failure(error)) 38 | } 39 | 40 | func completeInsertionSuccessfully(at index: Int = 0) { 41 | insertionCompletions[index](.success(())) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/ImageComments API/ImageCommentsMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCommentsMapper.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 27/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public final class ImageCommentsMapper { 12 | private struct Root: Decodable { 13 | private let items: [RemoteImageComment] 14 | 15 | private struct RemoteImageComment: Decodable { 16 | let id: UUID 17 | let message: String 18 | let created_at: Date 19 | let author: Author 20 | } 21 | 22 | private struct Author: Decodable { 23 | let username: String 24 | } 25 | 26 | var comments: [ImageComment] { 27 | items.map { ImageComment(id: $0.id, message: $0.message, createdAt: $0.created_at, username: $0.author.username) } 28 | } 29 | } 30 | 31 | public enum Error: Swift.Error { 32 | case invalidData 33 | } 34 | 35 | public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [ImageComment] { 36 | let decoder = JSONDecoder() 37 | decoder.dateDecodingStrategy = .iso8601 38 | guard isOK(response), let root = try? decoder.decode(Root.self, from: data) else { 39 | throw Error.invalidData 40 | } 41 | 42 | return root.comments 43 | } 44 | 45 | private static func isOK(_ response: HTTPURLResponse) -> Bool { 46 | (200...299).contains(response.statusCode) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Prototype/Prototype/FeedImageViewModel+PrototypeData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | extension FeedImageViewModel { 8 | static var prototypeFeed: [FeedImageViewModel] { 9 | return [ 10 | FeedImageViewModel( 11 | description: "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.", 12 | location: "East Side Gallery\nMemorial in Berlin, Germany", 13 | imageName: "image-0" 14 | ), 15 | FeedImageViewModel( 16 | description: nil, 17 | location: "Cannon Street, London", 18 | imageName: "image-1" 19 | ), 20 | FeedImageViewModel( 21 | description: "The Desert Island in Faro is beautiful!! ☀️", 22 | location: nil, 23 | imageName: "image-2" 24 | ), 25 | FeedImageViewModel( 26 | description: nil, 27 | location: nil, 28 | imageName: "image-3" 29 | ), 30 | FeedImageViewModel( 31 | description: "Garth Pier is a Grade II listed structure in Bangor, Gwynedd, North Wales. At 1,500 feet in length, it is the second-longest pier in Wales, and the ninth longest in the British Isles.", 32 | location: "Garth Pier\nNorth Wales", 33 | imageName: "image-4" 34 | ), 35 | FeedImageViewModel( 36 | description: "Glorious day in Brighton!! 🎢", 37 | location: "Brighton Seafront", 38 | imageName: "image-5" 39 | ) 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/FeedViewAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | import EssentialFeediOS 8 | 9 | final class FeedViewAdapter: ResourceView { 10 | private weak var controller: FeedViewController? 11 | private let imageLoader: (URL) -> FeedImageDataLoader.Publisher 12 | 13 | init(controller: FeedViewController, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { 14 | self.controller = controller 15 | self.imageLoader = imageLoader 16 | } 17 | 18 | func display(_ viewModel: FeedViewModel) { 19 | controller?.display(viewModel.feed.map { model in 20 | let adapter = LoadResourcePresentationAdapter>(loader: { [imageLoader] in 22 | imageLoader(model.url) 23 | }) 24 | 25 | let view = FeedImageCellController( 26 | viewModel: FeedImagePresenter.map(model), 27 | delegate: adapter) 28 | 29 | adapter.presenter = LoadResourcePresenter( 30 | resourceView: WeakRefVirtualProxy(view), 31 | loadingView: WeakRefVirtualProxy(view), 32 | errorView: WeakRefVirtualProxy(view), 33 | mapper: { data in 34 | guard let image = UIImage(data: data) else { 35 | throw InvalidImageData() 36 | } 37 | return image 38 | }) 39 | 40 | return view 41 | }) 42 | } 43 | } 44 | 45 | private struct InvalidImageData: Error { } 46 | -------------------------------------------------------------------------------- /feed_architecture.xml: -------------------------------------------------------------------------------- 1 | 7Vpdk5s2FP01PKYDksH4MfYu281sZppu0zRPHS1ojbKy5Qo5tvPrK0AC87XGNl552vjBgy4XSdxzdHQlYcHZYnvH0Sr+yCJMLWBHWwveWAB4LpD/qWGXG0YjJzfMOYly057hkfzAymgr65pEOKk4CsaoIKuqMWTLJQ5FxYY4Z5uq2zOj1VZXaI4bhscQ0ab1C4lEnFt91y7tv2Iyj3XLjq3uLJB2VoYkRhHb7JngrQVnnDGRXy22M0zT2Om45M8FHXeLjnG8FH0ewB+2/0ymYLv+w41vXPIIRvPgnarlO6Jr9cKf7/8keDNjS8EZpZirzoudjghn62WE00ptC043MRH4cYXC9O5GUkDaYrGgsuTIy2dC6YxRxmV5yZbSaZrIil+wNloA2nYQ2Gldz7LRNnvzTXW3MRd4u2dSb36H2QILvpMu6u5Iw6JpqFHZlKA62hbvATpSNqR4NC+qLkMtL1S0j4g8bAQWR5J5qsi4iNmcLRG9La3TauhLnwfGVirg37AQOzWM0FqwKhwygHz3V/r8L64uflXVZYWbbaW0K0rR+3QwyeITZeFLbgpI+sqZQwPUKfC8IHgNvISteYhfiZBSDoH4HIvDHE7D9yoVOKZIkO/VgT04ru414eqcg6tjGNfRVeEKGkoZYBwNqpVV+Ssi3UdDD8NyulZC17RWjhqxt4CHFmn8qGx7miLxwFCUIqDsc1EE45JzVwlSB3gXwAP4pvEYm9G4A3NQKYKVqa1UxA4RbElIxkEGXoSSOOuyc5bgeaYETz36GyOyywWfwKQ2vr0aT/KOqqdKqsjoo92e2yp1SLrbgeNaO7ZdY15eY8nD4h1Pp6bXkIrf8YIJvC8RA4p0QZV+ia5yHkAUoFMTBeMJ7eS6RcE5ThRqOAcBlL82UIs7A4mFf11i4cG3EQvXNSAWfkMsHphc+g+mFT00oaBPF+GGSCAm16YVjtOIrGmxOGd53IKqb2dbF3hLRF4hcFWxWJfJ67K+tKCrO1089KRwUD08M6lGsa03dKoxcavt2LW9sAOpCXgLtXGAGdJfYMundWughfTjqyK9fxHSHz3TgeO4Wp8Z34ar//HtyYKrA2VtvTl4XZtautuN5dKXexHnuRCi9AmFLxdaPxU49MuVlPMAORGs5bUObOZEwLtQTnTzA3399unu7/vkIZztwsmnz4RoZpgacWctjyQs9mRSH04tC2Dl1joIDo681rDBYzKet9b68ajKMv91qS929k5z19wcamZojbfZ840jV/E96djFZgM0dewzeXqWBpnZ2D1ljfT/YYRvkhC+WUL85EPHQaTpiezQ1DTuOLPq668X6BedymAj+U2zXAt42aHik8x0vewY8X2S4MVTdr4bTNeEZklwEKBQMDXELvx9TC8SNnLfFhp2psNebZHptG0R+i3pcH07uIfyyGL51VOOZfnpGLz9Fw== -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/RemoteFeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class RemoteFeedImageDataLoader: FeedImageDataLoader { 8 | private let client: HTTPClient 9 | 10 | public init(client: HTTPClient) { 11 | self.client = client 12 | } 13 | 14 | public enum Error: Swift.Error { 15 | case connectivity 16 | case invalidData 17 | } 18 | 19 | private final class HTTPClientTaskWrapper: FeedImageDataLoaderTask { 20 | private var completion: ((FeedImageDataLoader.Result) -> Void)? 21 | 22 | var wrapped: HTTPClientTask? 23 | 24 | init(_ completion: @escaping (FeedImageDataLoader.Result) -> Void) { 25 | self.completion = completion 26 | } 27 | 28 | func complete(with result: FeedImageDataLoader.Result) { 29 | completion?(result) 30 | } 31 | 32 | func cancel() { 33 | preventFurtherCompletions() 34 | wrapped?.cancel() 35 | wrapped = nil 36 | } 37 | 38 | private func preventFurtherCompletions() { 39 | completion = nil 40 | } 41 | } 42 | 43 | public func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { 44 | let task = HTTPClientTaskWrapper(completion) 45 | task.wrapped = client.get(from: url) { [weak self] result in 46 | guard self != nil else { return } 47 | 48 | task.complete(with: result 49 | .mapError { _ in Error.connectivity } 50 | .flatMap { (data, response) in 51 | let isValidResponse = response.isOK && !data.isEmpty 52 | return isValidResponse ? .success(data) : .failure(Error.invalidData) 53 | }) 54 | } 55 | return task 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/FeedUIComposer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | import EssentialFeediOS 8 | import Combine 9 | 10 | public final class FeedUIComposer { 11 | private init() {} 12 | 13 | typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter> 14 | 15 | public static func feedComposedWith(feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, 16 | imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) -> FeedViewController { 17 | let presentationAdapter = FeedPresentationAdapter(loader: { feedLoader().dispatchOnMainThread() }) 18 | 19 | let feedController = makeFeedViewController( 20 | delegate: presentationAdapter, 21 | title: FeedPresenter.title) 22 | 23 | presentationAdapter.presenter = LoadResourcePresenter( 24 | resourceView: FeedViewAdapter( 25 | controller: feedController, 26 | imageLoader: { imageLoader($0).dispatchOnMainThread() }), 27 | loadingView: WeakRefVirtualProxy(feedController), 28 | errorView: WeakRefVirtualProxy(feedController), 29 | mapper: FeedPresenter.map) 30 | 31 | return feedController 32 | } 33 | 34 | private static func makeFeedViewController(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController { 35 | let bundle = Bundle(for: FeedViewController.self) 36 | let storyboard = UIStoryboard(name: "Feed", bundle: bundle) 37 | let feedController = storyboard.instantiateInitialViewController() as! FeedViewController 38 | feedController.delegate = delegate 39 | feedController.title = title 40 | return feedController 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Prototype/Prototype/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Prototype/Prototype/FeedViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | struct FeedImageViewModel { 8 | let description: String? 9 | let location: String? 10 | let imageName: String 11 | } 12 | 13 | final class FeedViewController: UITableViewController { 14 | private var feed = [FeedImageViewModel]() 15 | 16 | override func viewWillAppear(_ animated: Bool) { 17 | super.viewWillAppear(animated) 18 | 19 | refresh() 20 | tableView.setContentOffset(CGPoint(x: 0, y: -tableView.contentInset.top), animated: false) 21 | } 22 | 23 | @IBAction func refresh() { 24 | refreshControl?.beginRefreshing() 25 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 26 | if self.feed.isEmpty { 27 | self.feed = FeedImageViewModel.prototypeFeed 28 | self.tableView.reloadData() 29 | } 30 | self.refreshControl?.endRefreshing() 31 | } 32 | } 33 | 34 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 35 | return feed.count 36 | } 37 | 38 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 39 | let cell = tableView.dequeueReusableCell(withIdentifier: "FeedImageCell", for: indexPath) as! FeedImageCell 40 | let model = feed[indexPath.row] 41 | cell.configure(with: model) 42 | return cell 43 | } 44 | 45 | } 46 | 47 | extension FeedImageCell { 48 | func configure(with model: FeedImageViewModel) { 49 | locationLabel.text = model.location 50 | locationContainer.isHidden = model.location == nil 51 | 52 | descriptionLabel.text = model.description 53 | descriptionLabel.isHidden = model.description == nil 54 | 55 | fadeIn(UIImage(named: model.imageName)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore2.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCommentsPresenter.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ImageCommentsViewModel { 12 | public let comments: [ImageCommentViewModel] 13 | } 14 | 15 | public struct ImageCommentViewModel: Equatable { 16 | public let message: String 17 | public let date: String 18 | public let username: String 19 | 20 | public init(message: String, date: String, username: String) { 21 | self.message = message 22 | self.date = date 23 | self.username = username 24 | } 25 | } 26 | 27 | public final class ImageCommentsPresenter { 28 | public static var title: String { 29 | return NSLocalizedString("IMAGE_COMMENTS_VIEW_TITLE", 30 | tableName: "ImageComments", 31 | bundle: Bundle(for: Self.self), 32 | comment: "Title for the feed view") 33 | } 34 | 35 | public static func map( 36 | _ comments: [ImageComment], 37 | currentDate: Date = Date(), 38 | calendar: Calendar = .current, 39 | locale: Locale = .current 40 | ) -> ImageCommentsViewModel { 41 | let formatter = RelativeDateTimeFormatter() 42 | formatter.calendar = calendar 43 | formatter.locale = locale 44 | 45 | return ImageCommentsViewModel(comments: comments.map { comment in 46 | ImageCommentViewModel( 47 | message: comment.message, 48 | date: formatter.localizedString(for: comment.createdAt, relativeTo: currentDate), 49 | username: comment.username) 50 | }) 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | import EssentialFeediOS 8 | 9 | extension FeedUIIntegrationTests { 10 | 11 | func assertThat(_ sut: FeedViewController, isRendering feed: [FeedImage], file: StaticString = #file, line: UInt = #line) { 12 | sut.view.enforceLayoutCycle() 13 | 14 | guard sut.numberOfRenderedFeedImageViews() == feed.count else { 15 | return XCTFail("Expected \(feed.count) images, got \(sut.numberOfRenderedFeedImageViews()) instead.", file: file, line: line) 16 | } 17 | 18 | feed.enumerated().forEach { index, image in 19 | assertThat(sut, hasViewConfiguredFor: image, at: index, file: file, line: line) 20 | } 21 | } 22 | 23 | func assertThat(_ sut: FeedViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #file, line: UInt = #line) { 24 | let view = sut.feedImageView(at: index) 25 | 26 | guard let cell = view as? FeedImageCell else { 27 | return XCTFail("Expected \(FeedImageCell.self) instance, got \(String(describing: view)) instead", file: file, line: line) 28 | } 29 | 30 | let shouldLocationBeVisible = (image.location != nil) 31 | XCTAssertEqual(cell.isShowingLocation, shouldLocationBeVisible, "Expected `isShowingLocation` to be \(shouldLocationBeVisible) for image view at index (\(index))", file: file, line: line) 32 | 33 | XCTAssertEqual(cell.locationText, image.location, "Expected location text to be \(String(describing: image.location)) for image view at index (\(index))", file: file, line: line) 34 | 35 | XCTAssertEqual(cell.descriptionText, image.description, "Expected description text to be \(String(describing: image.description)) for image view at index (\(index)", file: file, line: line) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared APU Infra/Helpers/URLProtocolStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | class URLProtocolStub: URLProtocol { 8 | private struct Stub { 9 | let data: Data? 10 | let response: URLResponse? 11 | let error: Error? 12 | let requestObserver: ((URLRequest) -> Void)? 13 | } 14 | 15 | private static var _stub: Stub? 16 | private static var stub: Stub? { 17 | get { return queue.sync { _stub } } 18 | set { queue.sync { _stub = newValue } } 19 | } 20 | 21 | private static let queue = DispatchQueue(label: "URLProtocolStub.queue") 22 | 23 | static func stub(data: Data?, response: URLResponse?, error: Error?) { 24 | stub = Stub(data: data, response: response, error: error, requestObserver: nil) 25 | } 26 | 27 | static func observeRequests(observer: @escaping (URLRequest) -> Void) { 28 | stub = Stub(data: nil, response: nil, error: nil, requestObserver: observer) 29 | } 30 | 31 | static func removeStub() { 32 | stub = nil 33 | } 34 | 35 | override class func canInit(with request: URLRequest) -> Bool { 36 | return true 37 | } 38 | 39 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 40 | return request 41 | } 42 | 43 | override func startLoading() { 44 | guard let stub = URLProtocolStub.stub else { return } 45 | 46 | if let data = stub.data { 47 | client?.urlProtocol(self, didLoad: data) 48 | } 49 | 50 | if let response = stub.response { 51 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 52 | } 53 | 54 | if let error = stub.error { 55 | client?.urlProtocol(self, didFailWithError: error) 56 | } else { 57 | client?.urlProtocolDidFinishLoading(self) 58 | } 59 | 60 | stub.requestObserver?(request) 61 | } 62 | 63 | override func stopLoading() {} 64 | } 65 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InMemoryFeedStore.swift 3 | // EssentialAppTests 4 | // 5 | // Created by Usemobile on 03/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import EssentialFeed 11 | 12 | class InMemoryFeedStore: FeedStore, FeedImageDataStore { 13 | private(set) var feedCache: CachedFeed? 14 | private var feedImageDataCache: [URL: Data] = [:] 15 | 16 | private init(feedCache: CachedFeed? = nil) { 17 | self.feedCache = feedCache 18 | } 19 | 20 | func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { 21 | feedCache = nil 22 | completion(.success(())) 23 | } 24 | 25 | func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { 26 | feedCache = CachedFeed(feed: feed, timestamp: timestamp) 27 | completion(.success(())) 28 | } 29 | 30 | func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { 31 | completion(.success(feedCache)) 32 | } 33 | 34 | func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { 35 | self.feedImageDataCache[url] = data 36 | completion(.success(())) 37 | } 38 | 39 | func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { 40 | completion(.success(feedImageDataCache[url])) 41 | } 42 | 43 | static var empty: InMemoryFeedStore { 44 | InMemoryFeedStore() 45 | } 46 | 47 | static var withExpiredFeedCache: InMemoryFeedStore { 48 | InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) 49 | } 50 | 51 | static var withNonExpiredFeedCache: InMemoryFeedStore { 52 | InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | 50 | UISupportedInterfaceOrientations~ipad 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationPortraitUpsideDown 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class LocalFeedImageDataLoader { 8 | private let store: FeedImageDataStore 9 | 10 | public init(store: FeedImageDataStore) { 11 | self.store = store 12 | } 13 | } 14 | 15 | extension LocalFeedImageDataLoader: FeedImageDataCache { 16 | public typealias SaveResult = FeedImageDataCache.Result 17 | 18 | public enum SaveError: Error { 19 | case failed 20 | } 21 | 22 | public func save(_ data: Data, for url: URL, completion: @escaping (SaveResult) -> Void) { 23 | store.insert(data, for: url) { [weak self] result in 24 | guard self != nil else { return } 25 | 26 | completion(result.mapError { _ in SaveError.failed }) 27 | } 28 | } 29 | } 30 | 31 | extension LocalFeedImageDataLoader: FeedImageDataLoader { 32 | public typealias LoadResult = FeedImageDataLoader.Result 33 | 34 | public enum LoadError: Error { 35 | case failed 36 | case notFound 37 | } 38 | 39 | private final class LoadImageDataTask: FeedImageDataLoaderTask { 40 | private var completion: ((FeedImageDataLoader.Result) -> Void)? 41 | 42 | init(_ completion: @escaping (FeedImageDataLoader.Result) -> Void) { 43 | self.completion = completion 44 | } 45 | 46 | func complete(with result: FeedImageDataLoader.Result) { 47 | completion?(result) 48 | } 49 | 50 | func cancel() { 51 | preventFurtherCompletions() 52 | } 53 | 54 | private func preventFurtherCompletions() { 55 | completion = nil 56 | } 57 | } 58 | 59 | public func loadImageData(from url: URL, completion: @escaping (LoadResult) -> Void) -> FeedImageDataLoaderTask { 60 | let task = LoadImageDataTask(completion) 61 | store.retrieve(dataForURL: url) { [weak self] result in 62 | guard self != nil else { return } 63 | 64 | task.complete(with: result 65 | .mapError { _ in LoadError.failed } 66 | .flatMap { data in 67 | data.map { .success($0) } ?? .failure(LoadError.notFound) 68 | }) 69 | } 70 | return task 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | 8 | public protocol FeedImageCellControllerDelegate { 9 | func didRequestImage() 10 | func didCancelImageRequest() 11 | } 12 | 13 | final public class FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { 14 | public typealias ResourceViewModel = UIImage 15 | 16 | private let viewModel: FeedImageViewModel 17 | private let delegate: FeedImageCellControllerDelegate 18 | private var cell: FeedImageCell? 19 | 20 | public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate) { 21 | self.viewModel = viewModel 22 | self.delegate = delegate 23 | } 24 | 25 | func view(in tableView: UITableView) -> UITableViewCell { 26 | cell = tableView.dequeueReusableCell() 27 | cell?.locationContainer.isHidden = !viewModel.hasLocation 28 | cell?.locationLabel.text = viewModel.location 29 | cell?.descriptionLabel.text = viewModel.description 30 | cell?.accessibilityIdentifier = "feed-image-cell" 31 | cell?.feedImageView.accessibilityIdentifier = "feed-image-view" 32 | cell?.onRetry = delegate.didRequestImage 33 | delegate.didRequestImage() 34 | return cell! 35 | } 36 | 37 | func preload() { 38 | delegate.didRequestImage() 39 | } 40 | 41 | func cancelLoad() { 42 | releaseCellForReuse() 43 | delegate.didCancelImageRequest() 44 | } 45 | 46 | public func display(_ viewModel: UIImage) { 47 | cell?.feedImageView.setImageAnimated(viewModel) 48 | } 49 | 50 | public func display(_ viewModel: ResourceLoadingViewModel) { 51 | cell?.feedImageContainer.isShimmering = viewModel.isLoading 52 | } 53 | 54 | public func display(_ viewModel: ResourceErrorViewModel) { 55 | cell?.feedImageRetryButton.isHidden = viewModel.message == nil 56 | } 57 | 58 | private func releaseCellForReuse() { 59 | cell = nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadResourcePresenter.swift 3 | // EssentialFeed 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ResourceView { 12 | associatedtype ResourceViewModel 13 | 14 | func display(_ viewModel: ResourceViewModel) 15 | } 16 | 17 | public final class LoadResourcePresenter { 18 | public typealias Mapper = (Resource) throws -> View.ResourceViewModel 19 | private let resourceView: View 20 | private let loadingView: ResourceLoadingView 21 | private let errorView: ResourceErrorView 22 | private let mapper: Mapper 23 | 24 | public static var loadError: String { 25 | return NSLocalizedString("GENERIC_CONNECTION_ERROR", 26 | tableName: "Shared", 27 | bundle: Bundle(for: Self.self), 28 | comment: "Error message displayed when we can't load the resource from the server") 29 | } 30 | 31 | public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView, mapper: @escaping Mapper) { 32 | self.resourceView = resourceView 33 | self.loadingView = loadingView 34 | self.errorView = errorView 35 | self.mapper = mapper 36 | } 37 | 38 | public func didStartLoading() { 39 | errorView.display(.noError) 40 | loadingView.display(ResourceLoadingViewModel(isLoading: true)) 41 | } 42 | 43 | public func didFinishLoading(with resource: Resource) { 44 | do { 45 | resourceView.display(try mapper(resource)) 46 | loadingView.display(ResourceLoadingViewModel(isLoading: false)) 47 | } catch { 48 | didFinishLoading(with: error) 49 | } 50 | } 51 | 52 | public func didFinishLoading(with error: Error) { 53 | errorView.display(.error(message: Self.loadError)) 54 | loadingView.display(ResourceLoadingViewModel(isLoading: false)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | class FeedStoreSpy: FeedStore { 9 | enum ReceivedMessage: Equatable { 10 | case deleteCachedFeed 11 | case insert([LocalFeedImage], Date) 12 | case retrieve 13 | } 14 | 15 | private(set) var receivedMessages = [ReceivedMessage]() 16 | 17 | private var deletionCompletions = [DeletionCompletion]() 18 | private var insertionCompletions = [InsertionCompletion]() 19 | private var retrievalCompletions = [RetrievalCompletion]() 20 | 21 | func deleteCachedFeed(completion: @escaping DeletionCompletion) { 22 | deletionCompletions.append(completion) 23 | receivedMessages.append(.deleteCachedFeed) 24 | } 25 | 26 | func completeDeletion(with error: Error, at index: Int = 0) { 27 | deletionCompletions[index](.failure(error)) 28 | } 29 | 30 | func completeDeletionSuccessfully(at index: Int = 0) { 31 | deletionCompletions[index](.success(())) 32 | } 33 | 34 | func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { 35 | insertionCompletions.append(completion) 36 | receivedMessages.append(.insert(feed, timestamp)) 37 | } 38 | 39 | func completeInsertion(with error: Error, at index: Int = 0) { 40 | insertionCompletions[index](.failure(error)) 41 | } 42 | 43 | func completeInsertionSuccessfully(at index: Int = 0) { 44 | insertionCompletions[index](.success(())) 45 | } 46 | 47 | func retrieve(completion: @escaping RetrievalCompletion) { 48 | retrievalCompletions.append(completion) 49 | receivedMessages.append(.retrieve) 50 | } 51 | 52 | func completeRetrieval(with error: Error, at index: Int = 0) { 53 | retrievalCompletions[index](.failure(error)) 54 | } 55 | 56 | func completeRetrievalWithEmptyCache(at index: Int = 0) { 57 | retrievalCompletions[index](.success(.none)) 58 | } 59 | 60 | func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { 61 | retrievalCompletions[index](.success(CachedFeed(feed: feed, timestamp: timestamp))) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Prototype/Prototype/FeedImageCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | final class FeedImageCell: UITableViewCell { 8 | @IBOutlet private(set) var locationContainer: UIView! 9 | @IBOutlet private(set) var locationLabel: UILabel! 10 | @IBOutlet private(set) var feedImageContainer: UIView! 11 | @IBOutlet private(set) var feedImageView: UIImageView! 12 | @IBOutlet private(set) var descriptionLabel: UILabel! 13 | 14 | override func awakeFromNib() { 15 | super.awakeFromNib() 16 | 17 | feedImageView.alpha = 0 18 | feedImageContainer.startShimmering() 19 | } 20 | 21 | override func prepareForReuse() { 22 | super.prepareForReuse() 23 | 24 | feedImageView.alpha = 0 25 | feedImageContainer.startShimmering() 26 | } 27 | 28 | func fadeIn(_ image: UIImage?) { 29 | feedImageView.image = image 30 | 31 | UIView.animate( 32 | withDuration: 0.25, 33 | delay: 1.25, 34 | options: [], 35 | animations: { 36 | self.feedImageView.alpha = 1 37 | }, completion: { completed in 38 | if completed { 39 | self.feedImageContainer.stopShimmering() 40 | } 41 | }) 42 | } 43 | } 44 | 45 | private extension UIView { 46 | private var shimmerAnimationKey: String { 47 | return "shimmer" 48 | } 49 | 50 | func startShimmering() { 51 | let white = UIColor.white.cgColor 52 | let alpha = UIColor.white.withAlphaComponent(0.7).cgColor 53 | let width = bounds.width 54 | let height = bounds.height 55 | 56 | let gradient = CAGradientLayer() 57 | gradient.colors = [alpha, white, alpha] 58 | gradient.startPoint = CGPoint(x: 0.0, y: 0.4) 59 | gradient.endPoint = CGPoint(x: 1.0, y: 0.6) 60 | gradient.locations = [0.4, 0.5, 0.6] 61 | gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) 62 | layer.mask = gradient 63 | 64 | let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) 65 | animation.fromValue = [0.0, 0.1, 0.2] 66 | animation.toValue = [0.8, 0.9, 1.0] 67 | animation.duration = 1 68 | animation.repeatCount = .infinity 69 | gradient.add(animation, forKey: shimmerAnimationKey) 70 | } 71 | 72 | func stopShimmering() { 73 | layer.mask = nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | import EssentialFeediOS 8 | import Combine 9 | 10 | extension FeedUIIntegrationTests { 11 | 12 | class LoaderSpy: FeedImageDataLoader { 13 | 14 | // MARK: - FeedLoader 15 | 16 | private var feedRequests = [PassthroughSubject<[FeedImage], Error>]() 17 | 18 | var loadFeedCallCount: Int { 19 | return feedRequests.count 20 | } 21 | 22 | func loadPublisher() -> AnyPublisher<[FeedImage], Error> { 23 | let publisher = PassthroughSubject<[FeedImage], Error>() 24 | feedRequests.append(publisher) 25 | return publisher.eraseToAnyPublisher() 26 | } 27 | 28 | func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { 29 | feedRequests[index].send(feed) 30 | } 31 | 32 | func completeFeedLoadingWithError(at index: Int = 0) { 33 | let error = NSError(domain: "an error", code: 0) 34 | feedRequests[index].send(completion: .failure(error)) 35 | } 36 | 37 | // MARK: - FeedImageDataLoader 38 | 39 | private struct TaskSpy: FeedImageDataLoaderTask { 40 | let cancelCallback: () -> Void 41 | func cancel() { 42 | cancelCallback() 43 | } 44 | } 45 | 46 | private var imageRequests = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]() 47 | 48 | var loadedImageURLs: [URL] { 49 | return imageRequests.map { $0.url } 50 | } 51 | 52 | private(set) var cancelledImageURLs = [URL]() 53 | 54 | func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { 55 | imageRequests.append((url, completion)) 56 | return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) } 57 | } 58 | 59 | func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { 60 | imageRequests[index].completion(.success(imageData)) 61 | } 62 | 63 | func completeImageLoadingWithError(at index: Int = 0) { 64 | let error = NSError(domain: "an error", code: 0) 65 | imageRequests[index].completion(.failure(error)) 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeediOS 7 | 8 | extension FeedViewController { 9 | func simulateUserInitiatedFeedReload() { 10 | refreshControl?.simulatePullToRefresh() 11 | } 12 | 13 | @discardableResult 14 | func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { 15 | return feedImageView(at: index) as? FeedImageCell 16 | } 17 | 18 | @discardableResult 19 | func simulateFeedImageViewNotVisible(at row: Int) -> FeedImageCell? { 20 | let view = simulateFeedImageViewVisible(at: row) 21 | 22 | let delegate = tableView.delegate 23 | let index = IndexPath(row: row, section: feedImagesSection) 24 | delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) 25 | 26 | return view 27 | } 28 | 29 | func simulateFeedImageViewNearVisible(at row: Int) { 30 | let ds = tableView.prefetchDataSource 31 | let index = IndexPath(row: row, section: feedImagesSection) 32 | ds?.tableView(tableView, prefetchRowsAt: [index]) 33 | } 34 | 35 | func simulateFeedImageViewNotNearVisible(at row: Int) { 36 | simulateFeedImageViewNearVisible(at: row) 37 | 38 | let ds = tableView.prefetchDataSource 39 | let index = IndexPath(row: row, section: feedImagesSection) 40 | ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) 41 | } 42 | 43 | func renderedFeedImageData(at index: Int) -> Data? { 44 | return simulateFeedImageViewVisible(at: index)?.renderedImage 45 | } 46 | 47 | var errorMessage: String? { 48 | return errorView?.message 49 | } 50 | 51 | var isShowingLoadingIndicator: Bool { 52 | return refreshControl?.isRefreshing == true 53 | } 54 | 55 | func numberOfRenderedFeedImageViews() -> Int { 56 | return tableView.numberOfRows(inSection: feedImagesSection) 57 | } 58 | 59 | func feedImageView(at row: Int) -> UITableViewCell? { 60 | guard numberOfRenderedFeedImageViews() > row else { 61 | return nil 62 | } 63 | let ds = tableView.dataSource 64 | let index = IndexPath(row: row, section: feedImagesSection) 65 | return ds?.tableView(tableView, cellForRowAt: index) 66 | } 67 | 68 | private var feedImagesSection: Int { 69 | return 0 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2018 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedItemsMapperTests: XCTestCase { 9 | 10 | func test_map_throwsErrorOnNon200HTTPResponse() throws { 11 | let samples = [199, 201, 300, 400, 500] 12 | 13 | try samples.enumerated().forEach { index, code in 14 | let json = makeItemsJSON([]) 15 | XCTAssertThrowsError( 16 | try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 17 | ) 18 | } 19 | } 20 | 21 | func test_map_throwsErrorOn200HTTPResponseWithInvalidJSON() { 22 | let invalidJSON = Data("invalid json".utf8) 23 | XCTAssertThrowsError( 24 | try FeedItemsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: 200)) 25 | ) 26 | } 27 | 28 | func test_map_throwsNoItemsOn200HTTPResponseWithEmptyJSONList() throws { 29 | let emptyListJSON = makeItemsJSON([]) 30 | let items = try FeedItemsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: 200)) 31 | XCTAssertTrue(items.isEmpty) 32 | } 33 | 34 | func test_map_throwsItemsOn200HTTPResponseWithJSONItems() throws { 35 | let item1 = makeItem( 36 | id: UUID(), 37 | imageURL: URL(string: "http://a-url.com")!) 38 | 39 | let item2 = makeItem( 40 | id: UUID(), 41 | description: "a description", 42 | location: "a location", 43 | imageURL: URL(string: "http://another-url.com")!) 44 | 45 | let items = [item1.model, item2.model] 46 | 47 | let json = makeItemsJSON([item1.json, item2.json]) 48 | let result = try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: 200)) 49 | XCTAssertEqual(items, result) 50 | } 51 | 52 | // MARK: - Helpers 53 | 54 | private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedImage, json: [String: Any]) { 55 | let item = FeedImage(id: id, description: description, location: location, url: imageURL) 56 | 57 | let json = [ 58 | "id": id.uuidString, 59 | "description": description, 60 | "location": location, 61 | "image": imageURL.absoluteString 62 | ].compactMapValues { $0 } 63 | 64 | return (item, json) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCommentsPresenterTests.swift 3 | // 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // 7 | 8 | import XCTest 9 | import EssentialFeed 10 | 11 | class ImageCommentsPresenterTests: XCTestCase { 12 | 13 | func test_title_isLocalized() { 14 | XCTAssertEqual(ImageCommentsPresenter.title, localized("FEED_VIEW_TITLE")) 15 | } 16 | 17 | func test_map_createsViewModels() { 18 | let now = Date() 19 | let calendar = Calendar(identifier: .gregorian) 20 | let locale = Locale(identifier: "en_US_POSIX") 21 | 22 | let comments = [ 23 | ImageComment(id: UUID(), 24 | message: "a message", 25 | createdAt: now.adding(minutes: -5, calendar: calendar), 26 | username: "a username"), 27 | ImageComment(id: UUID(), 28 | message: "another message", 29 | createdAt: now.adding(days: -1, calendar: calendar), 30 | username: "another username") 31 | ] 32 | 33 | let viewModels = ImageCommentsPresenter.map( 34 | comments, 35 | currentDate: now, 36 | calendar: calendar, 37 | locale: locale 38 | ) 39 | 40 | XCTAssertEqual(viewModels.comments, [ 41 | ImageCommentViewModel( 42 | message: "a message", 43 | date: "5 minutes ago", 44 | username: "a username" 45 | ), 46 | ImageCommentViewModel( 47 | message: "another message", 48 | date: "1 day ago", 49 | username: "another username" 50 | ) 51 | ]) 52 | } 53 | 54 | // MARK: - Helpers 55 | 56 | private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { 57 | let table = "Feed" 58 | let bundle = Bundle(for: ImageCommentsPresenter.self) 59 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 60 | if value == key { 61 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 62 | } 63 | return value 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/swift,xcode 2 | 3 | ### Swift ### 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## Build generated 9 | build/ 10 | DerivedData/ 11 | 12 | ## Various settings 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata/ 22 | 23 | ## Other 24 | *.moved-aside 25 | *.xccheckout 26 | *.xcscmblueprint 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | .build/ 45 | 46 | # CocoaPods 47 | # 48 | # We recommend against adding the Pods directory to your .gitignore. However 49 | # you should judge for yourself, the pros and cons are mentioned at: 50 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 51 | # 52 | # Pods/ 53 | # 54 | # Add this line if you want to avoid checking in source code from the Xcode workspace 55 | # *.xcworkspace 56 | 57 | # Carthage 58 | # 59 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 60 | # Carthage/Checkouts 61 | 62 | Carthage/Build 63 | 64 | # fastlane 65 | # 66 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 67 | # screenshots whenever they are needed. 68 | # For more information about the recommended setup visit: 69 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 70 | 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots/**/*.png 74 | fastlane/test_output 75 | 76 | ### Xcode ### 77 | # Xcode 78 | # 79 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 80 | 81 | ## User settings 82 | 83 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 84 | 85 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 86 | 87 | ### Xcode Patch ### 88 | *.xcodeproj/* 89 | !*.xcodeproj/project.pbxproj 90 | !*.xcodeproj/xcshareddata/ 91 | !*.xcworkspace/contents.xcworkspacedata 92 | /*.gcno 93 | 94 | # End of https://www.gitignore.io/api/swift,xcode -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { 9 | 10 | func test_retrieve_deliversEmptyOnEmptyCache() { 11 | let sut = makeSUT() 12 | 13 | assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) 14 | } 15 | 16 | func test_retrieve_hasNoSideEffectsOnEmptyCache() { 17 | let sut = makeSUT() 18 | 19 | assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) 20 | } 21 | 22 | func test_retrieve_deliversFoundValuesOnNonEmptyCache() { 23 | let sut = makeSUT() 24 | 25 | assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) 26 | } 27 | 28 | func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { 29 | let sut = makeSUT() 30 | 31 | assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) 32 | } 33 | 34 | func test_insert_deliversNoErrorOnEmptyCache() { 35 | let sut = makeSUT() 36 | 37 | assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) 38 | } 39 | 40 | func test_insert_deliversNoErrorOnNonEmptyCache() { 41 | let sut = makeSUT() 42 | 43 | assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) 44 | } 45 | 46 | func test_insert_overridesPreviouslyInsertedCacheValues() { 47 | let sut = makeSUT() 48 | 49 | assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) 50 | } 51 | 52 | func test_delete_deliversNoErrorOnEmptyCache() { 53 | let sut = makeSUT() 54 | 55 | assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) 56 | } 57 | 58 | func test_delete_hasNoSideEffectsOnEmptyCache() { 59 | let sut = makeSUT() 60 | 61 | assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) 62 | } 63 | 64 | func test_delete_deliversNoErrorOnNonEmptyCache() { 65 | let sut = makeSUT() 66 | 67 | assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) 68 | } 69 | 70 | func test_delete_emptiesPreviouslyInsertedCache() { 71 | let sut = makeSUT() 72 | 73 | assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) 74 | } 75 | 76 | func test_storeSideEffects_runSerially() { 77 | let sut = makeSUT() 78 | 79 | assertThatSideEffectsRunSerially(on: sut) 80 | } 81 | 82 | // - MARK: Helpers 83 | 84 | private func makeSUT(file: StaticString = #file, line: UInt = #line) -> FeedStore { 85 | let storeURL = URL(fileURLWithPath: "/dev/null") 86 | let sut = try! CoreDataFeedStore(storeURL: storeURL) 87 | trackForMemoryLeaks(sut, file: file, line: line) 88 | return sut 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharedLocalizationTestHelpers.swift 3 | // EssentialFeedTests 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | func assertLocalizedKeyAndValuesExist(in presentationBundle: Bundle, _ table: String, file: StaticString = #file, line: UInt = #line) { 12 | let localizationBundles = allLocalizationBundles(in: presentationBundle, file: file, line: line) 13 | let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table, file: file, line: line) 14 | 15 | localizationBundles.forEach { (bundle, localization) in 16 | localizedStringKeys.forEach { key in 17 | let localizedString = bundle.localizedString(forKey: key, value: nil, table: table) 18 | 19 | if localizedString == key { 20 | let language = Locale.current.localizedString(forLanguageCode: localization) ?? "" 21 | 22 | XCTFail("Missing \(language) (\(localization)) localized string for key: '\(key)' in table: '\(table)'", file: file, line: line) 23 | } 24 | } 25 | } 26 | } 27 | 28 | private typealias LocalizedBundle = (bundle: Bundle, localization: String) 29 | 30 | private func allLocalizationBundles(in bundle: Bundle, file: StaticString = #file, line: UInt = #line) -> [LocalizedBundle] { 31 | return bundle.localizations.compactMap { localization in 32 | guard 33 | let path = bundle.path(forResource: localization, ofType: "lproj"), 34 | let localizedBundle = Bundle(path: path) 35 | else { 36 | XCTFail("Couldn't find bundle for localization: \(localization)", file: file, line: line) 37 | return nil 38 | } 39 | 40 | return (localizedBundle, localization) 41 | } 42 | } 43 | 44 | private func allLocalizedStringKeys(in bundles: [LocalizedBundle], table: String, file: StaticString = #file, line: UInt = #line) -> Set { 45 | return bundles.reduce([]) { (acc, current) in 46 | guard 47 | let path = current.bundle.path(forResource: table, ofType: "strings"), 48 | let strings = NSDictionary(contentsOfFile: path), 49 | let keys = strings.allKeys as? [String] 50 | else { 51 | XCTFail("Couldn't load localized strings for localization: \(current.localization)", file: file, line: line) 52 | return acc 53 | } 54 | 55 | return acc.union(Set(keys)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedAPIEndToEndTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 16 | 17 | 23 | 24 | 25 | 26 | 29 | 35 | 36 | 37 | 38 | 39 | 49 | 50 | 56 | 57 | 59 | 60 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 16 | 17 | 23 | 24 | 25 | 26 | 29 | 35 | 36 | 37 | 38 | 39 | 49 | 50 | 56 | 57 | 59 | 60 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-40.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-60.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-58.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-87.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-80.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-120.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-121.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-180.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "Icon-20.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-41.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-29.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-59.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-42.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-81.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-76.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-152.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-167.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "Icon-1024.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | 8 | public protocol FeedViewControllerDelegate { 9 | func didRequestFeedRefresh() 10 | } 11 | 12 | public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, ResourceLoadingView, ResourceErrorView { 13 | public var delegate: FeedViewControllerDelegate? 14 | @IBOutlet private(set) public var errorView: ErrorView? 15 | 16 | private var loadingControllers = [IndexPath: FeedImageCellController]() 17 | 18 | private var tableModel = [FeedImageCellController]() { 19 | didSet { tableView.reloadData() } 20 | } 21 | 22 | public override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | refresh() 26 | } 27 | 28 | public override func viewDidLayoutSubviews() { 29 | super.viewDidLayoutSubviews() 30 | 31 | tableView.sizeTableHeaderToFit() 32 | } 33 | 34 | @IBAction private func refresh() { 35 | delegate?.didRequestFeedRefresh() 36 | } 37 | 38 | public func display(_ cellControllers: [FeedImageCellController]) { 39 | loadingControllers = [:] 40 | tableModel = cellControllers 41 | } 42 | 43 | public func display(_ viewModel: ResourceLoadingViewModel) { 44 | refreshControl?.update(isRefreshing: viewModel.isLoading) 45 | } 46 | 47 | public func display(_ viewModel: ResourceErrorViewModel) { 48 | errorView?.message = viewModel.message 49 | } 50 | 51 | public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 52 | return tableModel.count 53 | } 54 | 55 | public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 56 | return cellController(forRowAt: indexPath).view(in: tableView) 57 | } 58 | 59 | public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { 60 | cancelCellControllerLoad(forRowAt: indexPath) 61 | } 62 | 63 | public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { 64 | indexPaths.forEach { indexPath in 65 | cellController(forRowAt: indexPath).preload() 66 | } 67 | } 68 | 69 | public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { 70 | indexPaths.forEach(cancelCellControllerLoad) 71 | } 72 | 73 | private func cellController(forRowAt indexPath: IndexPath) -> FeedImageCellController { 74 | let controller = tableModel[indexPath.row] 75 | loadingControllers[indexPath] = controller 76 | return controller 77 | } 78 | 79 | private func cancelCellControllerLoad(forRowAt indexPath: IndexPath) { 80 | loadingControllers[indexPath]?.cancelLoad() 81 | loadingControllers[indexPath] = nil 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import CoreData 7 | import EssentialFeed 8 | import EssentialFeediOS 9 | import Combine 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | var window: UIWindow? 13 | 14 | lazy var httpClient: HTTPClient = { 15 | URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) 16 | }() 17 | lazy var store: FeedStore & FeedImageDataStore = { 18 | try! CoreDataFeedStore( 19 | storeURL: NSPersistentContainer 20 | .defaultDirectoryURL() 21 | .appendingPathComponent("feed-store.sqlite")) 22 | }() 23 | 24 | private lazy var localFeedLoader: LocalFeedLoader = { 25 | LocalFeedLoader(store: store, currentDate: Date.init) 26 | }() 27 | 28 | convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { 29 | self.init() 30 | self.httpClient = httpClient 31 | self.store = store 32 | } 33 | 34 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 35 | guard let scene = (scene as? UIWindowScene) else { return } 36 | 37 | window = UIWindow(windowScene: scene) 38 | configureWindow() 39 | } 40 | 41 | func configureWindow() { 42 | window?.rootViewController = UINavigationController(rootViewController: 43 | FeedUIComposer.feedComposedWith( 44 | feedLoader: makeRemoteFeedLoaderWithLocalFallBack, 45 | imageLoader: makeLocalImageLoaderWithRemoteFallBack)) 46 | 47 | window?.makeKeyAndVisible() 48 | } 49 | 50 | func sceneWillResignActive(_ scene: UIScene) { 51 | localFeedLoader.validateCache { _ in } 52 | } 53 | 54 | private func makeRemoteFeedLoaderWithLocalFallBack() -> AnyPublisher<[FeedImage], Error> { 55 | let remoteURL = URL(string: "https://static1.squarespace.com/static/5891c5b8d1758ec68ef5dbc2/t/5db4155a4fbade21d17ecd28/1572083034355/essential_app_feed.json")! 56 | 57 | return httpClient 58 | .getPublisher(url: remoteURL) 59 | .tryMap(FeedItemsMapper.map) 60 | .caching(to: localFeedLoader) 61 | .fallback(to: localFeedLoader.loadPublisher) 62 | } 63 | 64 | private func makeLocalImageLoaderWithRemoteFallBack(url: URL) -> FeedImageDataLoader.Publisher { 65 | let remoteImageLoader = RemoteFeedImageDataLoader(client: httpClient) 66 | let localImageLoader = LocalFeedImageDataLoader(store: store) 67 | 68 | return localImageLoader 69 | .loadImageDataPublisher(from: url) 70 | .fallback(to: { 71 | remoteImageLoader 72 | .loadImageDataPublisher(from: url) 73 | .caching(to: localImageLoader, using: url) 74 | }) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class LocalFeedLoader { 8 | private let store: FeedStore 9 | private let currentDate: () -> Date 10 | 11 | public init(store: FeedStore, currentDate: @escaping () -> Date) { 12 | self.store = store 13 | self.currentDate = currentDate 14 | } 15 | } 16 | 17 | extension LocalFeedLoader: FeedCache { 18 | public typealias SaveResult = FeedCache.Result 19 | 20 | public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { 21 | store.deleteCachedFeed { [weak self] deletionResult in 22 | guard let self = self else { return } 23 | 24 | switch deletionResult { 25 | case .success: 26 | self.cache(feed, with: completion) 27 | 28 | case let .failure(error): 29 | completion(.failure(error)) 30 | } 31 | } 32 | } 33 | 34 | private func cache(_ feed: [FeedImage], with completion: @escaping (SaveResult) -> Void) { 35 | store.insert(feed.toLocal(), timestamp: currentDate()) { [weak self] insertionResult in 36 | guard self != nil else { return } 37 | 38 | completion(insertionResult) 39 | } 40 | } 41 | } 42 | 43 | extension LocalFeedLoader { 44 | public typealias LoadResult = Swift.Result<[FeedImage], Error> 45 | 46 | public func load(completion: @escaping (LoadResult) -> Void) { 47 | store.retrieve { [weak self] result in 48 | guard let self = self else { return } 49 | 50 | switch result { 51 | case let .failure(error): 52 | completion(.failure(error)) 53 | 54 | case let .success(.some(cache)) where FeedCachePolicy.validate(cache.timestamp, against: self.currentDate()): 55 | completion(.success(cache.feed.toModels())) 56 | 57 | case .success: 58 | completion(.success([])) 59 | } 60 | } 61 | } 62 | } 63 | 64 | extension LocalFeedLoader { 65 | public typealias ValidationResult = Result 66 | 67 | public func validateCache(completion: @escaping (ValidationResult) -> Void) { 68 | store.retrieve { [weak self] result in 69 | guard let self = self else { return } 70 | 71 | switch result { 72 | case .failure: 73 | self.store.deleteCachedFeed(completion: completion) 74 | 75 | case let .success(.some(cache)) where !FeedCachePolicy.validate(cache.timestamp, against: self.currentDate()): 76 | self.store.deleteCachedFeed(completion: completion) 77 | 78 | case .success: 79 | completion(.success(())) 80 | } 81 | } 82 | } 83 | } 84 | 85 | private extension Array where Element == FeedImage { 86 | func toLocal() -> [LocalFeedImage] { 87 | return map { LocalFeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.url) } 88 | } 89 | } 90 | 91 | private extension Array where Element == LocalFeedImage { 92 | func toModels() -> [FeedImage] { 93 | return map { FeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.url) } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class CacheFeedImageDataUseCaseTests: XCTestCase { 9 | 10 | func test_init_doesNotMessageStoreUponCreation() { 11 | let (_, store) = makeSUT() 12 | 13 | XCTAssertTrue(store.receivedMessages.isEmpty) 14 | } 15 | 16 | func test_saveImageDataForURL_requestsImageDataInsertionForURL() { 17 | let (sut, store) = makeSUT() 18 | let url = anyURL() 19 | let data = anyData() 20 | 21 | sut.save(data, for: url) { _ in } 22 | 23 | XCTAssertEqual(store.receivedMessages, [.insert(data: data, for: url)]) 24 | } 25 | 26 | func test_saveImageDataFromURL_failsOnStoreInsertionError() { 27 | let (sut, store) = makeSUT() 28 | 29 | expect(sut, toCompleteWith: failed(), when: { 30 | let insertionError = anyNSError() 31 | store.completeInsertion(with: insertionError) 32 | }) 33 | } 34 | 35 | func test_saveImageDataFromURL_succeedsOnSuccessfulStoreInsertion() { 36 | let (sut, store) = makeSUT() 37 | 38 | expect(sut, toCompleteWith: .success(()), when: { 39 | store.completeInsertionSuccessfully() 40 | }) 41 | } 42 | 43 | func test_saveImageDataFromURL_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { 44 | let store = FeedImageDataStoreSpy() 45 | var sut: LocalFeedImageDataLoader? = LocalFeedImageDataLoader(store: store) 46 | 47 | var received = [LocalFeedImageDataLoader.SaveResult]() 48 | sut?.save(anyData(), for: anyURL()) { received.append($0) } 49 | 50 | sut = nil 51 | store.completeInsertionSuccessfully() 52 | 53 | XCTAssertTrue(received.isEmpty, "Expected no received results after instance has been deallocated") 54 | } 55 | 56 | // MARK: - Helpers 57 | 58 | private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { 59 | let store = FeedImageDataStoreSpy() 60 | let sut = LocalFeedImageDataLoader(store: store) 61 | trackForMemoryLeaks(store, file: file, line: line) 62 | trackForMemoryLeaks(sut, file: file, line: line) 63 | return (sut, store) 64 | } 65 | 66 | private func failed() -> LocalFeedImageDataLoader.SaveResult { 67 | return .failure(LocalFeedImageDataLoader.SaveError.failed) 68 | } 69 | 70 | private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: LocalFeedImageDataLoader.SaveResult, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { 71 | let exp = expectation(description: "Wait for load completion") 72 | 73 | sut.save(anyData(), for: anyURL()) { receivedResult in 74 | switch (receivedResult, expectedResult) { 75 | case (.success, .success): 76 | break 77 | 78 | case (.failure(let receivedError as LocalFeedImageDataLoader.SaveError), 79 | .failure(let expectedError as LocalFeedImageDataLoader.SaveError)): 80 | XCTAssertEqual(receivedError, expectedError, file: file, line: line) 81 | 82 | default: 83 | XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) 84 | } 85 | 86 | exp.fulfill() 87 | } 88 | 89 | action() 90 | wait(for: [exp], timeout: 1.0) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadImageCommensFromRemoteUseCaseTests.swift 3 | // EssentialFeedTests 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 27/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeed 11 | 12 | final class ImageCommentsMapperTests: XCTestCase { 13 | 14 | func test_map_throwsErrorOnNon2xxHTTPResponse() throws { 15 | let samples = [150, 199, 300, 400, 500] 16 | 17 | try samples.enumerated().forEach { index, code in 18 | let json = makeItemsJSON([]) 19 | XCTAssertThrowsError( 20 | try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 21 | ) 22 | } 23 | } 24 | 25 | func test_map_throwsErrorOn2xxHTTPResponseWithInvalidJSON() throws { 26 | let samples = [200, 201, 250, 280, 299] 27 | 28 | try samples.enumerated().forEach { index, code in 29 | let invalidJSON = Data("invalid json".utf8) 30 | XCTAssertThrowsError( 31 | try ImageCommentsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: code)) 32 | ) 33 | } 34 | } 35 | 36 | func test_map_throwsNoItemsOn2xxHTTPResponseWithEmptyJSONList() throws { 37 | let samples = [200, 201, 250, 280, 299] 38 | let emptyListJSON = makeItemsJSON([]) 39 | 40 | try samples.enumerated().forEach { index, code in 41 | let result = try ImageCommentsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: code)) 42 | XCTAssertTrue(result.isEmpty) 43 | } 44 | } 45 | 46 | func test_map_throwsItemsOn2xxHTTPResponseWithJSONItems() throws { 47 | let item1 = makeItem( 48 | id: UUID(), 49 | message: "a message", 50 | createdAt: (date: Date(timeIntervalSince1970: 1598627222), iso8601String: "2020-08-28T15:07:02+00:00"), 51 | username: "a username") 52 | 53 | let item2 = makeItem( 54 | id: UUID(), 55 | message: "another message", 56 | createdAt: (date: Date(timeIntervalSince1970: 1577881882), iso8601String: "2020-01-01T12:31:22+00:00"), 57 | username: "another username") 58 | 59 | let samples = [200, 201, 250, 280, 299] 60 | let json = makeItemsJSON([item1.json, item2.json]) 61 | 62 | try samples.enumerated().forEach { index, code in 63 | let result = try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 64 | XCTAssertEqual([item1.model, item2.model], result) 65 | } 66 | } 67 | 68 | // MARK: - Helpers 69 | 70 | private func makeItem(id: UUID, message: String, createdAt: (date: Date, iso8601String: String), username: String) -> (model: ImageComment, json: [String: Any]) { 71 | let item = ImageComment(id: id, message: message, createdAt: createdAt.date, username: username) 72 | 73 | let json: [String: Any] = [ 74 | "id": id.uuidString, 75 | "message": message, 76 | "created_at": createdAt.iso8601String, 77 | "author": [ 78 | "username": username 79 | ] 80 | ] 81 | 82 | return (item, json) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedAcceptanceTests.swift 3 | // EssentialAppTests 4 | // 5 | // Created by Usemobile on 03/06/20. 6 | // Copyright © 2020 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeed 11 | import EssentialFeediOS 12 | @testable import EssentialApp 13 | 14 | class FeedAcceptanceTests: XCTestCase { 15 | 16 | func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { 17 | let feed = launch(httpClient: .online(response), store: .empty) 18 | 19 | XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) 20 | XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData()) 21 | XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData()) 22 | } 23 | 24 | func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { 25 | let sharedStore = InMemoryFeedStore.empty 26 | let onlineFeed = launch(httpClient: .online(response), store: sharedStore) 27 | onlineFeed.simulateFeedImageViewVisible(at: 0) 28 | onlineFeed.simulateFeedImageViewVisible(at: 1) 29 | 30 | let offlineFeed = launch(httpClient: .offline, store: sharedStore) 31 | 32 | XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 2) 33 | XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData()) 34 | XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData()) 35 | } 36 | 37 | func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { 38 | let feed = launch(httpClient: .offline, store: .empty) 39 | 40 | XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) 41 | } 42 | 43 | func test_onEnteringBackground_deletesExpiredFeedCache() { 44 | let store = InMemoryFeedStore.withExpiredFeedCache 45 | 46 | enterBackground(with: store) 47 | 48 | XCTAssertNil(store.feedCache, "Expected to delete expired cache") 49 | } 50 | 51 | func test_onEnteringBackground_keepsNonExpiredFeedCache() { 52 | let store = InMemoryFeedStore.withNonExpiredFeedCache 53 | 54 | enterBackground(with: store) 55 | 56 | XCTAssertNotNil(store.feedCache, "Expected to delete expired cache") 57 | } 58 | 59 | // MARK: - Helpers 60 | 61 | private func launch( 62 | httpClient: HTTPClientStub = .offline, 63 | store: InMemoryFeedStore = .empty 64 | ) -> FeedViewController { 65 | let sut = SceneDelegate(httpClient: httpClient, store: store) 66 | sut.window = UIWindow() 67 | sut.configureWindow() 68 | 69 | let nav = sut.window?.rootViewController as? UINavigationController 70 | return nav?.topViewController as! FeedViewController 71 | } 72 | 73 | private func enterBackground(with store: InMemoryFeedStore) { 74 | let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) 75 | sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) 76 | } 77 | 78 | private func response(for url: URL) -> (Data, HTTPURLResponse) { 79 | let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! 80 | return (makeData(for: url), response) 81 | } 82 | 83 | private func makeData(for url: URL) -> Data { 84 | switch url.absoluteString { 85 | case "http://image.com": 86 | return makeImageData() 87 | 88 | default: 89 | return makeFeedData() 90 | } 91 | } 92 | 93 | private func makeImageData() -> Data { 94 | return UIImage.make(withColor: .red).pngData()! 95 | } 96 | 97 | private func makeFeedData() -> Data { 98 | return try! JSONSerialization.data(withJSONObject: ["items": [ 99 | ["id": UUID().uuidString, "image": "http://image.com"], 100 | ["id": UUID().uuidString, "image": "http://image.com"]]]) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed API/LoadFeedFromRemoteUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2018 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedItemsMapperTests: XCTestCase { 9 | 10 | func test_map_throwsErrorOnNon200HTTPResponse() throws { 11 | let samples = [199, 201, 300, 400, 500] 12 | 13 | try samples.enumerated().forEach { index, code in 14 | let json = makeItemsJSON([]) 15 | XCTAssertThrowsError( 16 | try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 17 | ) 18 | } 19 | } 20 | 21 | func test_map_throwsErrorOn200HTTPResponseWithInvalidJSON() { 22 | let invalidJSON = Data("invalid json".utf8) 23 | XCTAssertThrowsError( 24 | try FeedItemsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: 200)) 25 | ) 26 | } 27 | 28 | func test_map_throwsNoItemsOn200HTTPResponseWithEmptyJSONList() throws { 29 | let emptyListJSON = makeItemsJSON([]) 30 | let items = try FeedItemsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: 200)) 31 | XCTAssertTrue(items.isEmpty) 32 | } 33 | 34 | func test_map_throwsItemsOn200HTTPResponseWithJSONItems() throws { 35 | let item1 = makeItem( 36 | id: UUID(), 37 | imageURL: URL(string: "http://a-url.com")!) 38 | 39 | let item2 = makeItem( 40 | id: UUID(), 41 | description: "a description", 42 | location: "a location", 43 | imageURL: URL(string: "http://another-url.com")!) 44 | 45 | let items = [item1.model, item2.model] 46 | 47 | let json = makeItemsJSON([item1.json, item2.json]) 48 | let result = try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: 200)) 49 | XCTAssertEqual(items, result) 50 | } 51 | 52 | // MARK: - Helpers 53 | 54 | private func makeSUT(url: URL = URL(string: "https://a-url.com")!, file: StaticString = #file, line: UInt = #line) -> (sut: RemoteFeedLoader, client: HTTPClientSpy) { 55 | let client = HTTPClientSpy() 56 | let sut = RemoteFeedLoader(url: url, client: client) 57 | trackForMemoryLeaks(sut, file: file, line: line) 58 | trackForMemoryLeaks(client, file: file, line: line) 59 | return (sut, client) 60 | } 61 | 62 | private func failure(_ error: RemoteFeedLoader.Error) -> RemoteFeedLoader.Result { 63 | return .failure(error) 64 | } 65 | 66 | private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedImage, json: [String: Any]) { 67 | let item = FeedImage(id: id, description: description, location: location, url: imageURL) 68 | 69 | let json = [ 70 | "id": id.uuidString, 71 | "description": description, 72 | "location": location, 73 | "image": imageURL.absoluteString 74 | ].compactMapValues { $0 } 75 | 76 | return (item, json) 77 | } 78 | 79 | private func makeItemsJSON(_ items: [[String: Any]]) -> Data { 80 | let json = ["items": items] 81 | return try! JSONSerialization.data(withJSONObject: json) 82 | } 83 | 84 | private func expect(_ sut: RemoteFeedLoader, toCompleteWith expectedResult: RemoteFeedLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { 85 | let exp = expectation(description: "Wait for load completion") 86 | 87 | sut.load { receivedResult in 88 | switch (receivedResult, expectedResult) { 89 | case let (.success(receivedItems), .success(expectedItems)): 90 | XCTAssertEqual(receivedItems, expectedItems, file: file, line: line) 91 | 92 | case let (.failure(receivedError as RemoteFeedLoader.Error), .failure(expectedError as RemoteFeedLoader.Error)): 93 | XCTAssertEqual(receivedError, expectedError, file: file, line: line) 94 | 95 | default: 96 | XCTFail("Expected result \(expectedResult) got \(receivedResult) instead", file: file, line: line) 97 | } 98 | 99 | exp.fulfill() 100 | } 101 | 102 | action() 103 | 104 | wait(for: [exp], timeout: 1.0) 105 | } 106 | 107 | } 108 | 109 | private extension HTTPURLResponse { 110 | convenience init(statusCode: Int) { 111 | self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadResourcePresenterTests.swift 3 | // EssentialFeedTests 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 28/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import EssentialFeed 11 | 12 | class LoadResourcePresenterTests: XCTestCase { 13 | 14 | func test_init_doesNotSendMessagesToView() { 15 | let (_, view) = makeSUT() 16 | 17 | XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") 18 | } 19 | 20 | func test_didStartLoading_displaysNoErrorMessageAndStartsLoading() { 21 | let (sut, view) = makeSUT() 22 | 23 | sut.didStartLoading() 24 | 25 | XCTAssertEqual(view.messages, [ 26 | .display(errorMessage: .none), 27 | .display(isLoading: true) 28 | ]) 29 | } 30 | 31 | func test_didFinishLoadingResource_displaysResourceAndStopsLoading() { 32 | let (sut, view) = makeSUT(mapper: { resource in 33 | resource + " view model" 34 | }) 35 | 36 | sut.didFinishLoading(with: "resource") 37 | 38 | XCTAssertEqual(view.messages, [ 39 | .display(resourceViewModel: "resource view model"), 40 | .display(isLoading: false) 41 | ]) 42 | } 43 | 44 | func test_didFinishLoadingWithMapperError_displaysLocalizedErrorMessageAndStopsLoading() { 45 | let (sut, view) = makeSUT(mapper: { _ in 46 | throw anyNSError() 47 | }) 48 | 49 | sut.didFinishLoading(with: "resource") 50 | 51 | XCTAssertEqual(view.messages, [ 52 | .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), 53 | .display(isLoading: false) 54 | ]) 55 | } 56 | 57 | func test_didFinishLoadingWithError_displaysLocalizedErrorMessageAndStopsLoading() { 58 | let (sut, view) = makeSUT() 59 | 60 | sut.didFinishLoading(with: anyNSError()) 61 | 62 | XCTAssertEqual(view.messages, [ 63 | .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), 64 | .display(isLoading: false) 65 | ]) 66 | } 67 | 68 | // MARK: - Helpers 69 | 70 | private typealias SUT = LoadResourcePresenter 71 | 72 | private func makeSUT( 73 | mapper: @escaping SUT.Mapper = { _ in "any" }, 74 | file: StaticString = #file, 75 | line: UInt = #line 76 | ) -> (sut: SUT, view: ViewSpy) { 77 | let view = ViewSpy() 78 | let sut = SUT(resourceView: view, loadingView: view, errorView: view, mapper: mapper) 79 | trackForMemoryLeaks(view, file: file, line: line) 80 | trackForMemoryLeaks(sut, file: file, line: line) 81 | return (sut, view) 82 | } 83 | 84 | private func localized(_ key: String, table: String = "Shared", file: StaticString = #file, line: UInt = #line) -> String { 85 | let bundle = Bundle(for: SUT.self) 86 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 87 | if value == key { 88 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 89 | } 90 | return value 91 | } 92 | 93 | private class ViewSpy: ResourceView, ResourceLoadingView, ResourceErrorView { 94 | typealias ResourceViewModel = String 95 | 96 | enum Message: Hashable { 97 | case display(errorMessage: String?) 98 | case display(isLoading: Bool) 99 | case display(resourceViewModel: String) 100 | } 101 | 102 | private(set) var messages = Set() 103 | 104 | func display(_ viewModel: ResourceErrorViewModel) { 105 | messages.insert(.display(errorMessage: viewModel.message)) 106 | } 107 | 108 | func display(_ viewModel: ResourceLoadingViewModel) { 109 | messages.insert(.display(isLoading: viewModel.isLoading)) 110 | } 111 | 112 | func display(_ viewModel: ResourceViewModel) { 113 | messages.insert(.display(resourceViewModel: viewModel)) 114 | } 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 45 | 51 | 52 | 53 | 54 | 55 | 65 | 67 | 73 | 74 | 75 | 76 | 82 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class CacheFeedUseCaseTests: XCTestCase { 9 | 10 | func test_init_doesNotMessageStoreUponCreation() { 11 | let (_, store) = makeSUT() 12 | 13 | XCTAssertEqual(store.receivedMessages, []) 14 | } 15 | 16 | func test_save_requestsCacheDeletion() { 17 | let (sut, store) = makeSUT() 18 | 19 | sut.save(uniqueImageFeed().models) { _ in } 20 | 21 | XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) 22 | } 23 | 24 | func test_save_doesNotRequestCacheInsertionOnDeletionError() { 25 | let (sut, store) = makeSUT() 26 | let deletionError = anyNSError() 27 | 28 | sut.save(uniqueImageFeed().models) { _ in } 29 | store.completeDeletion(with: deletionError) 30 | 31 | XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) 32 | } 33 | 34 | func test_save_requestsNewCacheInsertionWithTimestampOnSuccessfulDeletion() { 35 | let timestamp = Date() 36 | let feed = uniqueImageFeed() 37 | let (sut, store) = makeSUT(currentDate: { timestamp }) 38 | 39 | sut.save(feed.models) { _ in } 40 | store.completeDeletionSuccessfully() 41 | 42 | XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(feed.local, timestamp)]) 43 | } 44 | 45 | func test_save_failsOnDeletionError() { 46 | let (sut, store) = makeSUT() 47 | let deletionError = anyNSError() 48 | 49 | expect(sut, toCompleteWithError: deletionError, when: { 50 | store.completeDeletion(with: deletionError) 51 | }) 52 | } 53 | 54 | func test_save_failsOnInsertionError() { 55 | let (sut, store) = makeSUT() 56 | let insertionError = anyNSError() 57 | 58 | expect(sut, toCompleteWithError: insertionError, when: { 59 | store.completeDeletionSuccessfully() 60 | store.completeInsertion(with: insertionError) 61 | }) 62 | } 63 | 64 | func test_save_succeedsOnSuccessfulCacheInsertion() { 65 | let (sut, store) = makeSUT() 66 | 67 | expect(sut, toCompleteWithError: nil, when: { 68 | store.completeDeletionSuccessfully() 69 | store.completeInsertionSuccessfully() 70 | }) 71 | } 72 | 73 | func test_save_doesNotDeliverDeletionErrorAfterSUTInstanceHasBeenDeallocated() { 74 | let store = FeedStoreSpy() 75 | var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) 76 | 77 | var receivedResults = [LocalFeedLoader.SaveResult]() 78 | sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } 79 | 80 | sut = nil 81 | store.completeDeletion(with: anyNSError()) 82 | 83 | XCTAssertTrue(receivedResults.isEmpty) 84 | } 85 | 86 | func test_save_doesNotDeliverInsertionErrorAfterSUTInstanceHasBeenDeallocated() { 87 | let store = FeedStoreSpy() 88 | var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) 89 | 90 | var receivedResults = [LocalFeedLoader.SaveResult]() 91 | sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } 92 | 93 | store.completeDeletionSuccessfully() 94 | sut = nil 95 | store.completeInsertion(with: anyNSError()) 96 | 97 | XCTAssertTrue(receivedResults.isEmpty) 98 | } 99 | 100 | // MARK: - Helpers 101 | 102 | private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { 103 | let store = FeedStoreSpy() 104 | let sut = LocalFeedLoader(store: store, currentDate: currentDate) 105 | trackForMemoryLeaks(store, file: file, line: line) 106 | trackForMemoryLeaks(sut, file: file, line: line) 107 | return (sut, store) 108 | } 109 | 110 | private func expect(_ sut: LocalFeedLoader, toCompleteWithError expectedError: NSError?, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { 111 | let exp = expectation(description: "Wait for save completion") 112 | 113 | var receivedError: Error? 114 | sut.save(uniqueImageFeed().models) { result in 115 | if case let Result.failure(error) = result { receivedError = error } 116 | exp.fulfill() 117 | } 118 | 119 | action() 120 | wait(for: [exp], timeout: 1.0) 121 | 122 | XCTAssertEqual(receivedError as NSError?, expectedError, file: file, line: line) 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class CoreDataFeedImageDataStoreTests: XCTestCase { 9 | 10 | func test_retrieveImageData_deliversNotFoundWhenEmpty() { 11 | let sut = makeSUT() 12 | 13 | expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) 14 | } 15 | 16 | func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() { 17 | let sut = makeSUT() 18 | let url = URL(string: "http://a-url.com")! 19 | let nonMatchingURL = URL(string: "http://another-url.com")! 20 | 21 | insert(anyData(), for: url, into: sut) 22 | 23 | expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) 24 | } 25 | 26 | func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() { 27 | let sut = makeSUT() 28 | let storedData = anyData() 29 | let matchingURL = URL(string: "http://a-url.com")! 30 | 31 | insert(storedData, for: matchingURL, into: sut) 32 | 33 | expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) 34 | } 35 | 36 | func test_retrieveImageData_deliversLastInsertedValue() { 37 | let sut = makeSUT() 38 | let firstStoredData = Data("first".utf8) 39 | let lastStoredData = Data("last".utf8) 40 | let url = URL(string: "http://a-url.com")! 41 | 42 | insert(firstStoredData, for: url, into: sut) 43 | insert(lastStoredData, for: url, into: sut) 44 | 45 | expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) 46 | } 47 | 48 | func test_sideEffects_runSerially() { 49 | let sut = makeSUT() 50 | let url = anyURL() 51 | 52 | let op1 = expectation(description: "Operation 1") 53 | sut.insert([localImage(url: url)], timestamp: Date()) { _ in 54 | op1.fulfill() 55 | } 56 | 57 | let op2 = expectation(description: "Operation 2") 58 | sut.insert(anyData(), for: url) { _ in op2.fulfill() } 59 | 60 | let op3 = expectation(description: "Operation 3") 61 | sut.insert(anyData(), for: url) { _ in op3.fulfill() } 62 | 63 | wait(for: [op1, op2, op3], timeout: 5.0, enforceOrder: true) 64 | } 65 | 66 | // - MARK: Helpers 67 | 68 | private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataFeedStore { 69 | let storeURL = URL(fileURLWithPath: "/dev/null") 70 | let sut = try! CoreDataFeedStore(storeURL: storeURL) 71 | trackForMemoryLeaks(sut, file: file, line: line) 72 | return sut 73 | } 74 | 75 | private func notFound() -> FeedImageDataStore.RetrievalResult { 76 | return .success(.none) 77 | } 78 | 79 | private func found(_ data: Data) -> FeedImageDataStore.RetrievalResult { 80 | return .success(data) 81 | } 82 | 83 | private func localImage(url: URL) -> LocalFeedImage { 84 | return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) 85 | } 86 | 87 | private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: FeedImageDataStore.RetrievalResult, for url: URL, file: StaticString = #file, line: UInt = #line) { 88 | let exp = expectation(description: "Wait for load completion") 89 | sut.retrieve(dataForURL: url) { receivedResult in 90 | switch (receivedResult, expectedResult) { 91 | case let (.success( receivedData), .success(expectedData)): 92 | XCTAssertEqual(receivedData, expectedData, file: file, line: line) 93 | 94 | default: 95 | XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) 96 | } 97 | exp.fulfill() 98 | } 99 | wait(for: [exp], timeout: 1.0) 100 | } 101 | 102 | private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #file, line: UInt = #line) { 103 | let exp = expectation(description: "Wait for cache insertion") 104 | let image = localImage(url: url) 105 | sut.insert([image], timestamp: Date()) { result in 106 | switch result { 107 | case let .failure(error): 108 | XCTFail("Failed to save \(image) with error \(error)", file: file, line: line) 109 | exp.fulfill() 110 | 111 | case .success: 112 | sut.insert(data, for: url) { result in 113 | if case let Result.failure(error) = result { 114 | XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) 115 | } 116 | exp.fulfill() 117 | } 118 | } 119 | } 120 | wait(for: [exp], timeout: 1.0) 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class LoadFeedImageDataFromCacheUseCaseTests: XCTestCase { 9 | 10 | func test_init_doesNotMessageStoreUponCreation() { 11 | let (_, store) = makeSUT() 12 | 13 | XCTAssertTrue(store.receivedMessages.isEmpty) 14 | } 15 | 16 | func test_loadImageDataFromURL_requestsStoredDataForURL() { 17 | let (sut, store) = makeSUT() 18 | let url = anyURL() 19 | 20 | _ = sut.loadImageData(from: url) { _ in } 21 | 22 | XCTAssertEqual(store.receivedMessages, [.retrieve(dataFor: url)]) 23 | } 24 | 25 | func test_loadImageDataFromURL_failsOnStoreError() { 26 | let (sut, store) = makeSUT() 27 | 28 | expect(sut, toCompleteWith: failed(), when: { 29 | let retrievalError = anyNSError() 30 | store.completeRetrieval(with: retrievalError) 31 | }) 32 | } 33 | 34 | func test_loadImageDataFromURL_deliversNotFoundErrorOnNotFound() { 35 | let (sut, store) = makeSUT() 36 | 37 | expect(sut, toCompleteWith: notFound(), when: { 38 | store.completeRetrieval(with: .none) 39 | }) 40 | } 41 | 42 | func test_loadImageDataFromURL_deliversStoredDataOnFoundData() { 43 | let (sut, store) = makeSUT() 44 | let foundData = anyData() 45 | 46 | expect(sut, toCompleteWith: .success(foundData), when: { 47 | store.completeRetrieval(with: foundData) 48 | }) 49 | } 50 | 51 | func test_loadImageDataFromURL_doesNotDeliverResultAfterCancellingTask() { 52 | let (sut, store) = makeSUT() 53 | let foundData = anyData() 54 | 55 | var received = [FeedImageDataLoader.Result]() 56 | let task = sut.loadImageData(from: anyURL()) { received.append($0) } 57 | task.cancel() 58 | 59 | store.completeRetrieval(with: foundData) 60 | store.completeRetrieval(with: .none) 61 | store.completeRetrieval(with: anyNSError()) 62 | 63 | XCTAssertTrue(received.isEmpty, "Expected no received results after cancelling task") 64 | } 65 | 66 | func test_loadImageDataFromURL_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { 67 | let store = FeedImageDataStoreSpy() 68 | var sut: LocalFeedImageDataLoader? = LocalFeedImageDataLoader(store: store) 69 | 70 | var received = [FeedImageDataLoader.Result]() 71 | _ = sut?.loadImageData(from: anyURL()) { received.append($0) } 72 | 73 | sut = nil 74 | store.completeRetrieval(with: anyData()) 75 | 76 | XCTAssertTrue(received.isEmpty, "Expected no received results after instance has been deallocated") 77 | } 78 | 79 | // MARK: - Helpers 80 | 81 | private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { 82 | let store = FeedImageDataStoreSpy() 83 | let sut = LocalFeedImageDataLoader(store: store) 84 | trackForMemoryLeaks(store, file: file, line: line) 85 | trackForMemoryLeaks(sut, file: file, line: line) 86 | return (sut, store) 87 | } 88 | 89 | private func failed() -> FeedImageDataLoader.Result { 90 | return .failure(LocalFeedImageDataLoader.LoadError.failed) 91 | } 92 | 93 | private func notFound() -> FeedImageDataLoader.Result { 94 | return .failure(LocalFeedImageDataLoader.LoadError.notFound) 95 | } 96 | 97 | private func never(file: StaticString = #file, line: UInt = #line) { 98 | XCTFail("Expected no no invocations", file: file, line: line) 99 | } 100 | 101 | private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: FeedImageDataLoader.Result, when action: () -> Void, file: StaticString = #file, line: UInt = #line) { 102 | let exp = expectation(description: "Wait for load completion") 103 | 104 | _ = sut.loadImageData(from: anyURL()) { receivedResult in 105 | switch (receivedResult, expectedResult) { 106 | case let (.success(receivedData), .success(expectedData)): 107 | XCTAssertEqual(receivedData, expectedData, file: file, line: line) 108 | 109 | case (.failure(let receivedError as LocalFeedImageDataLoader.LoadError), 110 | .failure(let expectedError as LocalFeedImageDataLoader.LoadError)): 111 | XCTAssertEqual(receivedError, expectedError, file: file, line: line) 112 | 113 | default: 114 | XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) 115 | } 116 | 117 | exp.fulfill() 118 | } 119 | 120 | action() 121 | wait(for: [exp], timeout: 1.0) 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 48 | 49 | 50 | 51 | 54 | 60 | 61 | 62 | 63 | 64 | 74 | 75 | 81 | 82 | 83 | 84 | 90 | 91 | 97 | 98 | 99 | 100 | 102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeediOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 48 | 49 | 50 | 51 | 54 | 60 | 61 | 62 | 63 | 64 | 74 | 75 | 81 | 82 | 83 | 84 | 90 | 91 | 97 | 98 | 99 | 100 | 102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/CombineHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineHelpers.swift 3 | // EssentialApp 4 | // 5 | // Created by Tulio de Oliveira Parreiras on 27/11/21. 6 | // Copyright © 2021 Essential Developer. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import EssentialFeed 12 | 13 | public extension HTTPClient { 14 | typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> 15 | 16 | func getPublisher(url: URL) -> Publisher { 17 | var task: HTTPClientTask? 18 | 19 | return Deferred { 20 | Future { completion in 21 | task = self.get(from: url, completion: completion) 22 | } 23 | } 24 | .handleEvents(receiveCancel: { task?.cancel() }) 25 | .eraseToAnyPublisher() 26 | } 27 | } 28 | 29 | public extension FeedImageDataLoader { 30 | typealias Publisher = AnyPublisher 31 | 32 | func loadImageDataPublisher(from url: URL) -> Publisher { 33 | var task: FeedImageDataLoaderTask? 34 | 35 | return Deferred { 36 | Future { completion in 37 | task = self.loadImageData(from: url, completion: completion) 38 | } 39 | } 40 | .handleEvents(receiveCancel: { task?.cancel() }) 41 | .eraseToAnyPublisher() 42 | } 43 | } 44 | 45 | extension Publisher where Output == Data { 46 | func caching(to cache: FeedImageDataCache, using url: URL) -> AnyPublisher { 47 | handleEvents(receiveOutput: { data in 48 | cache.saveIgnoringResult(data, for: url) 49 | }).eraseToAnyPublisher() 50 | } 51 | } 52 | 53 | private extension FeedImageDataCache { 54 | func saveIgnoringResult(_ data: Data, for url: URL) { 55 | save(data, for: url) { _ in } 56 | } 57 | } 58 | 59 | public extension LocalFeedLoader { 60 | typealias Publisher = AnyPublisher<[FeedImage], Error> 61 | 62 | func loadPublisher() -> Publisher { 63 | return Deferred { 64 | Future(self.load) 65 | } 66 | .eraseToAnyPublisher() 67 | } 68 | } 69 | 70 | extension Publisher where Output == [FeedImage] { 71 | func caching(to cache: FeedCache) -> AnyPublisher { 72 | handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher() 73 | } 74 | } 75 | 76 | private extension FeedCache { 77 | func saveIgnoringResult(_ feed: [FeedImage]) { 78 | save(feed) { _ in } 79 | } 80 | } 81 | 82 | extension Publisher { 83 | func fallback(to fallbackPublisher: @escaping () -> AnyPublisher) -> AnyPublisher { 84 | self.catch { _ in fallbackPublisher() }.eraseToAnyPublisher() 85 | } 86 | } 87 | 88 | extension Publisher { 89 | func dispatchOnMainThread() -> AnyPublisher { 90 | receive(on: DispatchQueue.immediateWhenOnMainQueueScheduler).eraseToAnyPublisher() 91 | } 92 | } 93 | 94 | extension DispatchQueue { 95 | 96 | static var immediateWhenOnMainQueueScheduler: ImmediateWhenOnMainQueueScheduler { 97 | ImmediateWhenOnMainQueueScheduler() 98 | } 99 | 100 | struct ImmediateWhenOnMainQueueScheduler: Scheduler { 101 | 102 | typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType 103 | typealias SchedulerOptions = DispatchQueue.SchedulerOptions 104 | 105 | var now: DispatchQueue.SchedulerTimeType { 106 | DispatchQueue.main.now 107 | } 108 | var minimumTolerance: DispatchQueue.SchedulerTimeType.Stride { 109 | DispatchQueue.main.minimumTolerance 110 | } 111 | 112 | func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { 113 | guard Thread.isMainThread else { 114 | return DispatchQueue.main.schedule(options: options, action) 115 | } 116 | 117 | action() 118 | } 119 | 120 | func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { 121 | DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: options, action) 122 | } 123 | 124 | func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable { 125 | DispatchQueue.main.schedule(after: date, interval: interval, tolerance: tolerance, options: options, action) 126 | } 127 | 128 | } 129 | } 130 | --------------------------------------------------------------------------------