├── 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