├── .github ├── deployment │ ├── ExportOptions.plist │ ├── certificate.p12.gpg │ └── profile.mobileprovision.gpg └── workflows │ ├── CI-iOS.yml │ ├── CI-macOS.yml │ └── Deploy.yml ├── .gitignore ├── EssentialApp ├── EssentialApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── IDETemplateMacros.plist ├── EssentialApp.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDETemplateMacros.plist │ │ ├── IDEWorkspaceChecks.plist │ │ └── xcschemes │ │ ├── CI_iOS.xcscheme │ │ └── EssentialApp.xcscheme ├── EssentialApp │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 120-1.png │ │ │ ├── 120.png │ │ │ ├── 152.png │ │ │ ├── 167.png │ │ │ ├── 180.png │ │ │ ├── 20.png │ │ │ ├── 29.png │ │ │ ├── 40-1.png │ │ │ ├── 40-2.png │ │ │ ├── 40.png │ │ │ ├── 58-1.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 76.png │ │ │ ├── 80-1.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── CombineHelpers.swift │ ├── CommentsUIComposer.swift │ ├── FeedUIComposer.swift │ ├── FeedViewAdapter.swift │ ├── Info.plist │ ├── LoadResourcePresentationAdapter.swift │ ├── SceneDelegate.swift │ ├── WeakRefVirtualProxy.swift │ ├── el.lproj │ │ └── LaunchScreen.strings │ ├── en.lproj │ │ └── LaunchScreen.strings │ └── pt-BR.lproj │ │ └── LaunchScreen.strings └── EssentialAppTests │ ├── CommentsUIIntegrationTests.swift │ ├── FeedAcceptanceTests.swift │ ├── FeedUIIntegrationTests.swift │ ├── Helpers │ ├── FeedImageCell+TestHelpers.swift │ ├── FeedUIIntegrationTests+Assertions.swift │ ├── FeedUIIntegrationTests+LoaderSpy.swift │ ├── HTTPClientStub.swift │ ├── ListViewController+TestHelpers.swift │ ├── SharedTestHelpers.swift │ ├── UIButton+TestHelpers.swift │ ├── UIControl+TestHelpers.swift │ ├── UIImage+TestHelpers.swift │ ├── UIRefreshControl+TestHelpers.swift │ ├── UIView+TestHelpers.swift │ └── XCTestCase+MemoryLeakTracking.swift │ ├── Info.plist │ └── SceneDelegateTests.swift ├── EssentialFeed ├── EssentialFeed.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ ├── IDETemplateMacros.plist │ │ └── xcschemes │ │ ├── CI_macOS.xcscheme │ │ ├── EssentialFeed.xcscheme │ │ ├── EssentialFeedAPIEndToEndTests.xcscheme │ │ ├── EssentialFeedCacheIntegrationTests.xcscheme │ │ └── EssentialFeediOS.xcscheme ├── EssentialFeed │ ├── Feed API │ │ ├── FeedEndpoint.swift │ │ ├── FeedImageDataMapper.swift │ │ ├── FeedItemsMapper.swift │ │ └── Helpers │ │ │ └── HTTPURLResponse+StatusCode.swift │ ├── Feed Cache │ │ ├── FeedCachePolicy.swift │ │ ├── FeedImageDataStore.swift │ │ ├── FeedStore.swift │ │ ├── Infrastructure │ │ │ ├── CoreData │ │ │ │ ├── CoreDataFeedStore+FeedImageDataStore.swift │ │ │ │ ├── CoreDataFeedStore+FeedStore.swift │ │ │ │ ├── CoreDataFeedStore.swift │ │ │ │ ├── CoreDataHelpers.swift │ │ │ │ ├── FeedStore.xcdatamodeld │ │ │ │ │ ├── .xccurrentversion │ │ │ │ │ ├── FeedStore.xcdatamodel │ │ │ │ │ │ └── contents │ │ │ │ │ └── FeedStore2.xcdatamodel │ │ │ │ │ │ └── contents │ │ │ │ ├── ManagedCache.swift │ │ │ │ └── ManagedFeedImage.swift │ │ │ └── InMemory │ │ │ │ └── InMemoryFeedStore.swift │ │ ├── LocalFeedImage.swift │ │ ├── LocalFeedImageDataLoader.swift │ │ └── LocalFeedLoader.swift │ ├── Feed Feature │ │ ├── FeedCache.swift │ │ ├── FeedImage.swift │ │ ├── FeedImageDataCache.swift │ │ └── FeedImageDataLoader.swift │ ├── Feed Presentation │ │ ├── FeedImagePresenter.swift │ │ ├── FeedImageViewModel.swift │ │ ├── FeedPresenter.swift │ │ ├── el.lproj │ │ │ └── Feed.strings │ │ ├── en.lproj │ │ │ └── Feed.strings │ │ └── pt-BR.lproj │ │ │ └── Feed.strings │ ├── Image Comments API │ │ ├── ImageCommentsEndpoint.swift │ │ └── ImageCommentsMapper.swift │ ├── Image Comments Feature │ │ └── ImageComment.swift │ ├── Image Comments Presentation │ │ ├── ImageCommentsPresenter.swift │ │ ├── el.lproj │ │ │ └── ImageComments.strings │ │ ├── en.lproj │ │ │ └── ImageComments.strings │ │ └── pt-BR.lproj │ │ │ └── ImageComments.strings │ ├── Info.plist │ ├── Shared API Infra │ │ └── URLSessionHTTPClient.swift │ ├── Shared API │ │ ├── HTTPClient.swift │ │ └── Paginated.swift │ └── Shared Presentation │ │ ├── LoadResourcePresenter.swift │ │ ├── ResourceErrorView.swift │ │ ├── ResourceErrorViewModel.swift │ │ ├── ResourceLoadingView.swift │ │ ├── ResourceLoadingViewModel.swift │ │ ├── el.lproj │ │ └── Shared.strings │ │ ├── en.lproj │ │ └── Shared.strings │ │ └── pt-BR.lproj │ │ └── Shared.strings ├── EssentialFeedAPIEndToEndTests │ ├── EssentialFeedAPIEndToEndTests.swift │ └── Info.plist ├── EssentialFeedCacheIntegrationTests │ ├── EssentialFeedCacheIntegrationTests.swift │ └── Info.plist ├── EssentialFeedTests │ ├── Feed API │ │ ├── FeedEndpointTests.swift │ │ ├── FeedImageDataMapperTests.swift │ │ └── FeedItemsMapperTests.swift │ ├── Feed Cache │ │ ├── CacheFeedImageDataUseCaseTests.swift │ │ ├── CacheFeedUseCaseTests.swift │ │ ├── CoreDataFeedImageDataStoreTests.swift │ │ ├── CoreDataFeedStoreTests.swift │ │ ├── FeedImageDataStoreSpecs │ │ │ ├── FeedImageDataStoreSpecs.swift │ │ │ └── XCTestCase+FeedImageDataStoreSpecs.swift │ │ ├── FeedStoreSpecs │ │ │ ├── FeedStoreSpecs.swift │ │ │ ├── XCTestCase+FailableDeleteFeedStoreSpecs.swift │ │ │ ├── XCTestCase+FailableInsertFeedStoreSpecs.swift │ │ │ ├── XCTestCase+FailableRetrieveFeedStoreSpecs.swift │ │ │ └── XCTestCase+FeedStoreSpecs.swift │ │ ├── Helpers │ │ │ ├── FeedCacheTestHelpers.swift │ │ │ ├── FeedImageDataStoreSpy.swift │ │ │ └── FeedStoreSpy.swift │ │ ├── InMemoryFeedImageDataStoreTests.swift │ │ ├── InMemoryFeedStoreTests.swift │ │ ├── LoadFeedFromCacheUseCaseTests.swift │ │ ├── LoadFeedImageDataFromCacheUseCaseTests.swift │ │ └── ValidateFeedCacheUseCaseTests.swift │ ├── Feed Presentation │ │ ├── FeedImagePresenterTests.swift │ │ ├── FeedLocalizationTests.swift │ │ └── FeedPresenterTests.swift │ ├── Helpers │ │ ├── SharedLocalizationTestHelpers.swift │ │ ├── SharedTestHelpers.swift │ │ └── XCTestCase+MemoryLeakTracking.swift │ ├── Image Comments API │ │ ├── ImageCommentsEndpointTests.swift │ │ └── ImageCommentsMapperTests.swift │ ├── Image Comments Presentation │ │ ├── ImageCommentsLocalizationTests.swift │ │ └── ImageCommentsPresenterTests.swift │ ├── Info.plist │ ├── Shared API Infra │ │ ├── Helpers │ │ │ └── URLProtocolStub.swift │ │ └── URLSessionHTTPClientTests.swift │ └── Shared Presentation │ │ ├── LoadResourcePresenterTests.swift │ │ └── SharedLocalizationTests.swift ├── EssentialFeediOS │ ├── Feed UI │ │ ├── Controllers │ │ │ ├── FeedImageCellController.swift │ │ │ └── LoadMoreCellController.swift │ │ └── Views │ │ │ ├── Feed.storyboard │ │ │ ├── Feed.xcassets │ │ │ ├── Contents.json │ │ │ └── pin.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── pin.png │ │ │ │ ├── pin@2x.png │ │ │ │ └── pin@3x.png │ │ │ ├── FeedImageCell.swift │ │ │ ├── Helpers │ │ │ ├── UIImageView+Animations.swift │ │ │ └── UIView+Shimmering.swift │ │ │ └── LoadMoreCell.swift │ ├── Image Comments UI │ │ ├── Controllers │ │ │ └── ImageCommentCellController.swift │ │ └── Views │ │ │ ├── ImageCommentCell.swift │ │ │ └── ImageComments.storyboard │ ├── Info.plist │ └── Shared UI │ │ ├── Controllers │ │ ├── CellController.swift │ │ └── ListViewController.swift │ │ └── Views │ │ ├── ErrorView.swift │ │ └── Helpers │ │ ├── UIRefreshControl+Helpers.swift │ │ ├── UITableView+Dequeueing.swift │ │ ├── UITableView+HeaderSizing.swift │ │ └── UIView+Container.swift └── EssentialFeediOSTests │ ├── Feed UI │ ├── FeedSnapshotTests.swift │ └── snapshots │ │ ├── FEED_WITH_CONTENT_dark.png │ │ ├── FEED_WITH_CONTENT_light.png │ │ ├── FEED_WITH_CONTENT_light_extraExtraExtraLarge.png │ │ ├── FEED_WITH_FAILED_IMAGE_LOADING_dark.png │ │ ├── FEED_WITH_FAILED_IMAGE_LOADING_light.png │ │ ├── FEED_WITH_LOAD_MORE_ERROR_dark.png │ │ ├── FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png │ │ ├── FEED_WITH_LOAD_MORE_ERROR_light.png │ │ ├── FEED_WITH_LOAD_MORE_INDICATOR_dark.png │ │ └── FEED_WITH_LOAD_MORE_INDICATOR_light.png │ ├── Helpers │ ├── UIImage+TestHelpers.swift │ ├── UIViewController+Snapshot.swift │ └── XCTestCase+Snapshot.swift │ ├── Image Comments UI │ ├── ImageCommentsSnapshotTests.swift │ └── snapshots │ │ ├── IMAGE_COMMENTS_dark.png │ │ ├── IMAGE_COMMENTS_light.png │ │ └── IMAGE_COMMENTS_light_extraExtraExtraLarge.png │ ├── Info.plist │ └── Shared UI │ ├── ListSnapshotTests.swift │ └── snapshots │ ├── EMPTY_LIST_dark.png │ ├── EMPTY_LIST_light.png │ ├── LIST_WITH_ERROR_MESSAGE_dark.png │ ├── LIST_WITH_ERROR_MESSAGE_light.png │ └── LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png ├── LICENSE.md ├── Prototype ├── Prototype.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Prototype │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-1024.png │ │ ├── Icon-120.png │ │ ├── Icon-121.png │ │ ├── Icon-152.png │ │ ├── Icon-167.png │ │ ├── Icon-180.png │ │ ├── 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 │ ├── Contents.json │ ├── image-0.imageset │ │ ├── Contents.json │ │ └── image-0.jpg │ ├── image-1.imageset │ │ ├── Contents.json │ │ └── image-1.jpg │ ├── image-2.imageset │ │ ├── Contents.json │ │ └── image-2.jpg │ ├── image-3.imageset │ │ ├── Contents.json │ │ └── image-3.jpg │ ├── image-4.imageset │ │ ├── Contents.json │ │ └── image-4.jpg │ ├── image-5.imageset │ │ ├── Contents.json │ │ └── image-5.jpg │ └── pin.imageset │ │ ├── Contents.json │ │ ├── pin.png │ │ ├── pin@2x.png │ │ └── pin@3x.png │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── FeedImageCell.swift │ ├── FeedImageViewModel+PrototypeData.swift │ ├── FeedViewController.swift │ └── Info.plist ├── README.md ├── architecture.png └── feed_flowchart.png /.github/deployment/ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | destination 6 | export 7 | method 8 | app-store 9 | provisioningProfiles 10 | 11 | com.essentialdeveloper.EssentialAppCaseStudy 12 | Essential App Case Study (Production) 13 | 14 | signingCertificate 15 | iPhone Distribution 16 | signingStyle 17 | manual 18 | stripSwiftSymbols 19 | 20 | teamID 21 | VRJ2W4578X 22 | uploadBitcode 23 | 24 | uploadSymbols 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/deployment/certificate.p12.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/.github/deployment/certificate.p12.gpg -------------------------------------------------------------------------------- /.github/deployment/profile.mobileprovision.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/.github/deployment/profile.mobileprovision.gpg -------------------------------------------------------------------------------- /.github/workflows/CI-iOS.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI-iOS 3 | 4 | # Controls when the action will run. 5 | # Triggers the workflow on pull request events but only for the master branch. 6 | on: 7 | pull_request: 8 | branches: [ master ] 9 | 10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 11 | jobs: 12 | # This workflow contains a single job called "build-and-test" 13 | build-and-test: 14 | # The type of runner that the job will run on 15 | runs-on: macos-15-xlarge 16 | 17 | timeout-minutes: 8 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v4 23 | 24 | - name: Select Xcode 25 | run: sudo xcode-select -switch /Applications/Xcode_16.2.app 26 | 27 | - name: Xcode version 28 | run: /usr/bin/xcodebuild -version 29 | 30 | - name: Build and Test 31 | run: xcodebuild clean build test -workspace EssentialApp/EssentialApp.xcworkspace -scheme "CI_iOS" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2" ONLY_ACTIVE_ARCH=YES 32 | -------------------------------------------------------------------------------- /.github/workflows/CI-macOS.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI-macOS 3 | 4 | # Controls when the action will run. 5 | # Triggers the workflow on pull request events but only for the master branch. 6 | on: 7 | pull_request: 8 | branches: [ master ] 9 | 10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 11 | jobs: 12 | # This workflow contains a single job called "build-and-test" 13 | build-and-test: 14 | # The type of runner that the job will run on 15 | runs-on: macos-15 16 | 17 | timeout-minutes: 8 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v4 23 | 24 | - name: Select Xcode 25 | run: sudo xcode-select -switch /Applications/Xcode_16.2.app 26 | 27 | - name: Xcode version 28 | run: /usr/bin/xcodebuild -version 29 | 30 | - name: Build and Test 31 | run: 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 32 | -------------------------------------------------------------------------------- /.github/workflows/Deploy.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Deploy 3 | 4 | # Controls when the action will run. 5 | # Triggers the workflow on push events but only for the master branch. 6 | on: 7 | push: 8 | branches: [ master ] 9 | 10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 11 | jobs: 12 | # This workflow contains a single job called "build-and-deploy" 13 | build-and-deploy: 14 | # The type of runner that the job will run on 15 | runs-on: macos-15 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install provisioning profile 23 | run: | 24 | gpg --quiet --batch --yes --decrypt --passphrase="${{ secrets.SECRET_KEY }}" --output .github/deployment/profile.mobileprovision .github/deployment/profile.mobileprovision.gpg 25 | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles 26 | cp .github/deployment/profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ 27 | 28 | - name: Install keychain certificate 29 | run: | 30 | gpg --quiet --batch --yes --decrypt --passphrase="${{ secrets.SECRET_KEY }}" --output .github/deployment/certificate.p12 .github/deployment/certificate.p12.gpg 31 | security create-keychain -p "" build.keychain 32 | security import .github/deployment/certificate.p12 -t agg -k ~/Library/Keychains/build.keychain -P "${{ secrets.CERTIFICATE_PASSWORD }}" -A 33 | security list-keychains -s ~/Library/Keychains/build.keychain 34 | security default-keychain -s ~/Library/Keychains/build.keychain 35 | security unlock-keychain -p "" ~/Library/Keychains/build.keychain 36 | security set-key-partition-list -S apple-tool:,apple: -s -k "" ~/Library/Keychains/build.keychain 37 | 38 | - name: Select Xcode 39 | run: sudo xcode-select -switch /Applications/Xcode_16.2.app 40 | 41 | - name: Xcode version 42 | run: /usr/bin/xcodebuild -version 43 | 44 | - name: Set build number 45 | run: | 46 | buildNumber=$(($GITHUB_RUN_NUMBER + 1)) 47 | /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "EssentialApp/EssentialApp/Info.plist" 48 | 49 | - name: Build 50 | run: xcodebuild clean archive -sdk iphoneos -workspace EssentialApp/EssentialApp.xcworkspace -configuration "Release" -scheme "EssentialApp" -derivedDataPath "DerivedData" -archivePath "DerivedData/Archive/EssentialApp.xcarchive" 51 | 52 | - name: Export 53 | run: xcodebuild -exportArchive -archivePath DerivedData/Archive/EssentialApp.xcarchive -exportOptionsPlist .github/deployment/ExportOptions.plist -exportPath DerivedData/ipa 54 | 55 | - name: Deploy 56 | run: xcrun altool --upload-app --type ios --file "DerivedData/ipa/EssentialApp.ipa" --username "${{ secrets.APPSTORE_USERNAME }}" --password "${{ secrets.APPSTORE_PASSWORD }}" --verbose 57 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Copyright © Essential Developer. All rights reserved. 8 | // 9 | 10 | 11 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Copyright © Essential Developer. All rights reserved. 8 | // 9 | 10 | 11 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | @UIApplicationMain 8 | class AppDelegate: UIResponder, UIApplicationDelegate {} 9 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/120-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/120-1.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40-1.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40-2.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/58-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/58-1.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/80-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/80-1.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120-1.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "40-1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "58-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "40-2.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "80-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/CommentsUIComposer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import Combine 7 | import EssentialFeed 8 | import EssentialFeediOS 9 | 10 | public final class CommentsUIComposer { 11 | private init() {} 12 | 13 | private typealias CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> 14 | 15 | public static func commentsComposedWith( 16 | commentsLoader: @escaping () -> AnyPublisher<[ImageComment], Error> 17 | ) -> ListViewController { 18 | let presentationAdapter = CommentsPresentationAdapter(loader: commentsLoader) 19 | 20 | let commentsController = makeCommentsViewController(title: ImageCommentsPresenter.title) 21 | commentsController.onRefresh = presentationAdapter.loadResource 22 | 23 | presentationAdapter.presenter = LoadResourcePresenter( 24 | resourceView: CommentsViewAdapter(controller: commentsController), 25 | loadingView: WeakRefVirtualProxy(commentsController), 26 | errorView: WeakRefVirtualProxy(commentsController), 27 | mapper: { ImageCommentsPresenter.map($0) }) 28 | 29 | return commentsController 30 | } 31 | 32 | private static func makeCommentsViewController(title: String) -> ListViewController { 33 | let bundle = Bundle(for: ListViewController.self) 34 | let storyboard = UIStoryboard(name: "ImageComments", bundle: bundle) 35 | let controller = storyboard.instantiateInitialViewController() as! ListViewController 36 | controller.title = title 37 | return controller 38 | } 39 | } 40 | 41 | final class CommentsViewAdapter: ResourceView { 42 | private weak var controller: ListViewController? 43 | 44 | init(controller: ListViewController) { 45 | self.controller = controller 46 | } 47 | 48 | func display(_ viewModel: ImageCommentsViewModel) { 49 | controller?.display(viewModel.comments.map { viewModel in 50 | CellController(id: viewModel, ImageCommentCellController(model: viewModel)) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/FeedUIComposer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import Combine 7 | import EssentialFeed 8 | import EssentialFeediOS 9 | 10 | public final class FeedUIComposer { 11 | private init() {} 12 | 13 | private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> 14 | 15 | public static func feedComposedWith( 16 | feedLoader: @escaping () -> AnyPublisher, Error>, 17 | imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, 18 | selection: @escaping (FeedImage) -> Void = { _ in } 19 | ) -> ListViewController { 20 | let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) 21 | 22 | let feedController = makeFeedViewController(title: FeedPresenter.title) 23 | feedController.onRefresh = presentationAdapter.loadResource 24 | 25 | presentationAdapter.presenter = LoadResourcePresenter( 26 | resourceView: FeedViewAdapter( 27 | controller: feedController, 28 | imageLoader: imageLoader, 29 | selection: selection), 30 | loadingView: WeakRefVirtualProxy(feedController), 31 | errorView: WeakRefVirtualProxy(feedController)) 32 | 33 | return feedController 34 | } 35 | 36 | private static func makeFeedViewController(title: String) -> ListViewController { 37 | let bundle = Bundle(for: ListViewController.self) 38 | let storyboard = UIStoryboard(name: "Feed", bundle: bundle) 39 | let feedController = storyboard.instantiateInitialViewController() as! ListViewController 40 | feedController.title = title 41 | return feedController 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/FeedViewAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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: ListViewController? 11 | private let imageLoader: (URL) -> FeedImageDataLoader.Publisher 12 | private let selection: (FeedImage) -> Void 13 | private let currentFeed: [FeedImage: CellController] 14 | 15 | private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> 16 | private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> 17 | 18 | init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) { 19 | self.currentFeed = currentFeed 20 | self.controller = controller 21 | self.imageLoader = imageLoader 22 | self.selection = selection 23 | } 24 | 25 | func display(_ viewModel: Paginated) { 26 | guard let controller = controller else { return } 27 | 28 | var currentFeed = self.currentFeed 29 | let feed: [CellController] = viewModel.items.map { model in 30 | if let controller = currentFeed[model] { 31 | return controller 32 | } 33 | 34 | let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in 35 | imageLoader(model.url) 36 | }) 37 | 38 | let view = FeedImageCellController( 39 | viewModel: FeedImagePresenter.map(model), 40 | delegate: adapter, 41 | selection: { [selection] in 42 | selection(model) 43 | }) 44 | 45 | adapter.presenter = LoadResourcePresenter( 46 | resourceView: WeakRefVirtualProxy(view), 47 | loadingView: WeakRefVirtualProxy(view), 48 | errorView: WeakRefVirtualProxy(view), 49 | mapper: UIImage.tryMake) 50 | 51 | let controller = CellController(id: model, view) 52 | currentFeed[model] = controller 53 | return controller 54 | } 55 | 56 | guard let loadMorePublisher = viewModel.loadMorePublisher else { 57 | controller.display(feed) 58 | return 59 | } 60 | 61 | let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMorePublisher) 62 | let loadMore = LoadMoreCellController(callback: loadMoreAdapter.loadResource) 63 | 64 | loadMoreAdapter.presenter = LoadResourcePresenter( 65 | resourceView: FeedViewAdapter( 66 | currentFeed: currentFeed, 67 | controller: controller, 68 | imageLoader: imageLoader, 69 | selection: selection 70 | ), 71 | loadingView: WeakRefVirtualProxy(loadMore), 72 | errorView: WeakRefVirtualProxy(loadMore)) 73 | 74 | let loadMoreSection = [CellController(id: UUID(), loadMore)] 75 | 76 | controller.display(feed, loadMoreSection) 77 | } 78 | } 79 | 80 | extension UIImage { 81 | struct InvalidImageData: Error {} 82 | 83 | static func tryMake(data: Data) throws -> UIImage { 84 | guard let image = UIImage(data: data) else { 85 | throw InvalidImageData() 86 | } 87 | return image 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /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 | ITSAppUsesNonExemptEncryption 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Combine 6 | import EssentialFeed 7 | import EssentialFeediOS 8 | 9 | final class LoadResourcePresentationAdapter { 10 | private let loader: () -> AnyPublisher 11 | private var cancellable: Cancellable? 12 | private var isLoading = false 13 | 14 | var presenter: LoadResourcePresenter? 15 | 16 | init(loader: @escaping () -> AnyPublisher) { 17 | self.loader = loader 18 | } 19 | 20 | func loadResource() { 21 | guard !isLoading else { return } 22 | 23 | presenter?.didStartLoading() 24 | isLoading = true 25 | 26 | cancellable = loader() 27 | .dispatchOnMainThread() 28 | .handleEvents(receiveCancel: { [weak self] in 29 | self?.isLoading = false 30 | }) 31 | .sink( 32 | receiveCompletion: { [weak self] completion in 33 | switch completion { 34 | case .finished: break 35 | 36 | case let .failure(error): 37 | self?.presenter?.didFinishLoading(with: error) 38 | } 39 | 40 | self?.isLoading = false 41 | }, receiveValue: { [weak self] resource in 42 | self?.presenter?.didFinishLoading(with: resource) 43 | }) 44 | } 45 | } 46 | 47 | extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { 48 | func didRequestImage() { 49 | loadResource() 50 | } 51 | 52 | func didCancelImageRequest() { 53 | cancellable?.cancel() 54 | cancellable = nil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/WeakRefVirtualProxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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(_ model: UIImage) { 30 | object?.display(model) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/el.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/en.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /EssentialApp/EssentialApp/pt-BR.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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: ListViewController, isRendering feed: [FeedImage], file: StaticString = #filePath, 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 | executeRunLoopToCleanUpReferences() 23 | } 24 | 25 | func assertThat(_ sut: ListViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #filePath, line: UInt = #line) { 26 | let view = sut.feedImageView(at: index) 27 | 28 | guard let cell = view as? FeedImageCell else { 29 | return XCTFail("Expected \(FeedImageCell.self) instance, got \(String(describing: view)) instead", file: file, line: line) 30 | } 31 | 32 | let shouldLocationBeVisible = (image.location != nil) 33 | XCTAssertEqual(cell.isShowingLocation, shouldLocationBeVisible, "Expected `isShowingLocation` to be \(shouldLocationBeVisible) for image view at index (\(index))", file: file, line: line) 34 | 35 | XCTAssertEqual(cell.locationText, image.location, "Expected location text to be \(String(describing: image.location)) for image view at index (\(index))", file: file, line: line) 36 | 37 | XCTAssertEqual(cell.descriptionText, image.description, "Expected description text to be \(String(describing: image.description)) for image view at index (\(index)", file: file, line: line) 38 | } 39 | 40 | private func executeRunLoopToCleanUpReferences() { 41 | RunLoop.current.run(until: Date()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 { 13 | 14 | // MARK: - FeedLoader 15 | 16 | private var feedRequests = [PassthroughSubject, Error>]() 17 | 18 | var loadFeedCallCount: Int { 19 | return feedRequests.count 20 | } 21 | 22 | func loadPublisher() -> AnyPublisher, Error> { 23 | let publisher = PassthroughSubject, Error>() 24 | feedRequests.append(publisher) 25 | return publisher.eraseToAnyPublisher() 26 | } 27 | 28 | func completeFeedLoadingWithError(at index: Int = 0) { 29 | feedRequests[index].send(completion: .failure(anyNSError())) 30 | } 31 | 32 | func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { 33 | feedRequests[index].send(Paginated(items: feed, loadMorePublisher: { [weak self] in 34 | self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() 35 | })) 36 | feedRequests[index].send(completion: .finished) 37 | } 38 | 39 | // MARK: - LoadMoreFeedLoader 40 | 41 | private var loadMoreRequests = [PassthroughSubject, Error>]() 42 | 43 | var loadMoreCallCount: Int { 44 | return loadMoreRequests.count 45 | } 46 | 47 | func loadMorePublisher() -> AnyPublisher, Error> { 48 | let publisher = PassthroughSubject, Error>() 49 | loadMoreRequests.append(publisher) 50 | return publisher.eraseToAnyPublisher() 51 | } 52 | 53 | func completeLoadMore(with feed: [FeedImage] = [], lastPage: Bool = false, at index: Int = 0) { 54 | loadMoreRequests[index].send(Paginated( 55 | items: feed, 56 | loadMorePublisher: lastPage ? nil : { [weak self] in 57 | self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() 58 | })) 59 | } 60 | 61 | func completeLoadMoreWithError(at index: Int = 0) { 62 | loadMoreRequests[index].send(completion: .failure(anyNSError())) 63 | } 64 | 65 | // MARK: - FeedImageDataLoader 66 | 67 | private var imageRequests = [(url: URL, publisher: PassthroughSubject)]() 68 | 69 | var loadedImageURLs: [URL] { 70 | return imageRequests.map { $0.url } 71 | } 72 | 73 | private(set) var cancelledImageURLs = [URL]() 74 | 75 | func loadImageDataPublisher(from url: URL) -> AnyPublisher { 76 | let publisher = PassthroughSubject() 77 | imageRequests.append((url, publisher)) 78 | return publisher.handleEvents(receiveCancel: { [weak self] in 79 | self?.cancelledImageURLs.append(url) 80 | }).eraseToAnyPublisher() 81 | } 82 | 83 | func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { 84 | imageRequests[index].publisher.send(imageData) 85 | imageRequests[index].publisher.send(completion: .finished) 86 | } 87 | 88 | func completeImageLoadingWithError(at index: Int = 0) { 89 | imageRequests[index].publisher.send(completion: .failure(anyNSError())) 90 | } 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import EssentialFeed 7 | 8 | class HTTPClientStub: HTTPClient { 9 | private class Task: HTTPClientTask { 10 | func cancel() {} 11 | } 12 | 13 | private let stub: (URL) -> HTTPClient.Result 14 | 15 | init(stub: @escaping (URL) -> HTTPClient.Result) { 16 | self.stub = stub 17 | } 18 | 19 | func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { 20 | completion(stub(url)) 21 | return Task() 22 | } 23 | } 24 | 25 | extension HTTPClientStub { 26 | static var offline: HTTPClientStub { 27 | HTTPClientStub(stub: { _ in .failure(NSError(domain: "offline", code: 0)) }) 28 | } 29 | 30 | static func online(_ stub: @escaping (URL) -> (Data, HTTPURLResponse)) -> HTTPClientStub { 31 | HTTPClientStub { url in .success(stub(url)) } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | 24 | private class DummyView: ResourceView { 25 | func display(_ viewModel: Any) {} 26 | } 27 | 28 | var loadError: String { 29 | LoadResourcePresenter.loadError 30 | } 31 | 32 | var feedTitle: String { 33 | FeedPresenter.title 34 | } 35 | 36 | var commentsTitle: String { 37 | ImageCommentsPresenter.title 38 | } 39 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/UIControl+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/UIImage+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | let format = UIGraphicsImageRendererFormat() 11 | format.scale = 1 12 | 13 | return UIGraphicsImageRenderer(size: rect.size, format: format).image { rendererContext in 14 | color.setFill() 15 | rendererContext.fill(rect) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIRefreshControl { 8 | func simulatePullToRefresh() { 9 | simulate(event: .valueChanged) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/UIView+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIView { 8 | func enforceLayoutCycle() { 9 | layoutIfNeeded() 10 | RunLoop.current.run(until: Date()) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | extension XCTestCase { 8 | func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, 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 | -------------------------------------------------------------------------------- /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/SceneDelegateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeediOS 7 | @testable import EssentialApp 8 | 9 | class SceneDelegateTests: XCTestCase { 10 | 11 | func test_configureWindow_setsWindowAsKeyAndVisible() { 12 | let window = UIWindowSpy() 13 | let sut = SceneDelegate() 14 | sut.window = window 15 | 16 | sut.configureWindow() 17 | 18 | XCTAssertEqual(window.makeKeyAndVisibleCallCount, 1, "Expected to make window key and visible") 19 | } 20 | 21 | func test_configureWindow_configuresRootViewController() { 22 | let sut = SceneDelegate() 23 | sut.window = UIWindowSpy() 24 | 25 | sut.configureWindow() 26 | 27 | let root = sut.window?.rootViewController 28 | let rootNavigation = root as? UINavigationController 29 | let topController = rootNavigation?.topViewController 30 | 31 | XCTAssertNotNil(rootNavigation, "Expected a navigation controller as root, got \(String(describing: root)) instead") 32 | XCTAssertTrue(topController is ListViewController, "Expected a feed controller as top view controller, got \(String(describing: topController)) instead") 33 | } 34 | 35 | private class UIWindowSpy: UIWindow { 36 | var makeKeyAndVisibleCallCount = 0 37 | 38 | override func makeKeyAndVisible() { 39 | makeKeyAndVisibleCallCount += 1 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Copyright © Essential Developer. All rights reserved. 8 | // 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | 53 | 54 | 55 | 56 | 62 | 63 | 65 | 66 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum FeedEndpoint { 8 | case get(after: FeedImage? = nil) 9 | 10 | public func url(baseURL: URL) -> URL { 11 | switch self { 12 | case let .get(image): 13 | var components = URLComponents() 14 | components.scheme = baseURL.scheme 15 | components.host = baseURL.host 16 | components.path = baseURL.path + "/v1/feed" 17 | components.queryItems = [ 18 | URLQueryItem(name: "limit", value: "10"), 19 | image.map { URLQueryItem(name: "after_id", value: $0.id.uuidString) }, 20 | ].compactMap { $0 } 21 | return components.url! 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/FeedImageDataMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class FeedImageDataMapper { 8 | public enum Error: Swift.Error { 9 | case invalidData 10 | } 11 | 12 | public static func map(_ data: Data, from response: HTTPURLResponse) throws -> Data { 13 | guard response.isOK, !data.isEmpty else { 14 | throw Error.invalidData 15 | } 16 | 17 | return data 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed API/FeedItemsMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeed/Feed API/Helpers/HTTPURLResponse+StatusCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 Cache/FeedCachePolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeed/Feed Cache/FeedImageDataStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataStore { 8 | func insert(_ data: Data, for url: URL) throws 9 | func retrieve(dataForURL url: URL) throws -> Data? 10 | } 11 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | func deleteCachedFeed() throws 11 | func insert(_ feed: [LocalFeedImage], timestamp: Date) throws 12 | func retrieve() throws -> CachedFeed? 13 | } 14 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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) throws { 10 | try ManagedFeedImage.first(with: url, in: context) 11 | .map { $0.data = data } 12 | .map(context.save) 13 | } 14 | 15 | public func retrieve(dataForURL url: URL) throws -> Data? { 16 | try ManagedFeedImage.data(with: url, in: context) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import CoreData 6 | 7 | extension CoreDataFeedStore: FeedStore { 8 | 9 | public func retrieve() throws -> CachedFeed? { 10 | try ManagedCache.find(in: context).map { 11 | CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) 12 | } 13 | } 14 | 15 | public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { 16 | let managedCache = try ManagedCache.newUniqueInstance(in: context) 17 | managedCache.timestamp = timestamp 18 | managedCache.feed = ManagedFeedImage.images(from: feed, in: context) 19 | try context.save() 20 | } 21 | 22 | public func deleteCachedFeed() throws { 23 | try ManagedCache.deleteCache(in: context) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | let context: NSManagedObjectContext 13 | 14 | enum StoreError: Error { 15 | case modelNotFound 16 | case failedToLoadPersistentContainer(Error) 17 | } 18 | 19 | public enum ContextQueue { 20 | case main 21 | case background 22 | } 23 | 24 | public var contextQueue: ContextQueue { 25 | context == container.viewContext ? .main : .background 26 | } 27 | 28 | public init(storeURL: URL, contextQueue: ContextQueue = .background) throws { 29 | guard let model = CoreDataFeedStore.model else { 30 | throw StoreError.modelNotFound 31 | } 32 | 33 | do { 34 | container = try NSPersistentContainer.load(name: CoreDataFeedStore.modelName, model: model, url: storeURL) 35 | context = contextQueue == .main ? container.viewContext : container.newBackgroundContext() 36 | } catch { 37 | throw StoreError.failedToLoadPersistentContainer(error) 38 | } 39 | } 40 | 41 | public func perform(_ action: @escaping () -> Void) { 42 | context.perform(action) 43 | } 44 | 45 | private func cleanUpReferencesToPersistentStores() { 46 | context.performAndWait { 47 | let coordinator = self.container.persistentStoreCoordinator 48 | try? coordinator.persistentStores.forEach(coordinator.remove) 49 | } 50 | } 51 | 52 | deinit { 53 | cleanUpReferencesToPersistentStores() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | FeedStore2.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/Feed Cache/Infrastructure/CoreData/ManagedCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 deleteCache(in context: NSManagedObjectContext) throws { 21 | try find(in: context).map(context.delete).map(context.save) 22 | } 23 | 24 | static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { 25 | try deleteCache(in: context) 26 | return ManagedCache(context: context) 27 | } 28 | 29 | var localFeed: [LocalFeedImage] { 30 | return feed.compactMap { ($0 as? ManagedFeedImage)?.local } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 data(with url: URL, in context: NSManagedObjectContext) throws -> Data? { 19 | if let data = context.userInfo[url] as? Data { return data } 20 | 21 | return try first(with: url, in: context)?.data 22 | } 23 | 24 | static func first(with url: URL, in context: NSManagedObjectContext) throws -> ManagedFeedImage? { 25 | let request = NSFetchRequest(entityName: entity().name!) 26 | request.predicate = NSPredicate(format: "%K = %@", argumentArray: [#keyPath(ManagedFeedImage.url), url]) 27 | request.returnsObjectsAsFaults = false 28 | request.fetchLimit = 1 29 | return try context.fetch(request).first 30 | } 31 | 32 | static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { 33 | let images = NSOrderedSet(array: localFeed.map { local in 34 | let managed = ManagedFeedImage(context: context) 35 | managed.id = local.id 36 | managed.imageDescription = local.description 37 | managed.location = local.location 38 | managed.url = local.url 39 | managed.data = context.userInfo[local.url] as? Data 40 | return managed 41 | }) 42 | context.userInfo.removeAllObjects() 43 | return images 44 | } 45 | 46 | var local: LocalFeedImage { 47 | return LocalFeedImage(id: id, description: imageDescription, location: location, url: url) 48 | } 49 | 50 | override func prepareForDeletion() { 51 | super.prepareForDeletion() 52 | 53 | managedObjectContext?.userInfo[url] = data 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public class InMemoryFeedStore { 8 | private var feedCache: CachedFeed? 9 | private var feedImageDataCache = NSCache() 10 | 11 | public init() {} 12 | } 13 | 14 | extension InMemoryFeedStore: FeedStore { 15 | public func deleteCachedFeed() throws { 16 | feedCache = nil 17 | } 18 | 19 | public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { 20 | feedCache = CachedFeed(feed: feed, timestamp: timestamp) 21 | } 22 | 23 | public func retrieve() throws -> CachedFeed? { 24 | feedCache 25 | } 26 | } 27 | 28 | extension InMemoryFeedStore: FeedImageDataStore { 29 | public func insert(_ data: Data, for url: URL) throws { 30 | feedImageDataCache.setObject(data as NSData, forKey: url as NSURL) 31 | } 32 | 33 | public func retrieve(dataForURL url: URL) throws -> Data? { 34 | feedImageDataCache.object(forKey: url as NSURL) as Data? 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 Cache/LocalFeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 enum SaveError: Error { 17 | case failed 18 | } 19 | 20 | public func save(_ data: Data, for url: URL) throws { 21 | do { 22 | try store.insert(data, for: url) 23 | } catch { 24 | throw SaveError.failed 25 | } 26 | } 27 | } 28 | 29 | extension LocalFeedImageDataLoader: FeedImageDataLoader { 30 | public enum LoadError: Error { 31 | case failed 32 | case notFound 33 | } 34 | 35 | public func loadImageData(from url: URL) throws -> Data { 36 | do { 37 | if let imageData = try store.retrieve(dataForURL: url) { 38 | return imageData 39 | } 40 | } catch { 41 | throw LoadError.failed 42 | } 43 | 44 | throw LoadError.notFound 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 func save(_ feed: [FeedImage]) throws { 19 | try store.deleteCachedFeed() 20 | try store.insert(feed.toLocal(), timestamp: currentDate()) 21 | } 22 | } 23 | 24 | extension LocalFeedLoader { 25 | public func load() throws -> [FeedImage] { 26 | if let cache = try store.retrieve(), FeedCachePolicy.validate(cache.timestamp, against: currentDate()) { 27 | return cache.feed.toModels() 28 | } 29 | return [] 30 | } 31 | } 32 | 33 | extension LocalFeedLoader { 34 | private struct InvalidCache: Error {} 35 | 36 | public func validateCache() throws { 37 | do { 38 | if let cache = try store.retrieve(), !FeedCachePolicy.validate(cache.timestamp, against: currentDate()) { 39 | throw InvalidCache() 40 | } 41 | } catch { 42 | try store.deleteCachedFeed() 43 | } 44 | } 45 | } 46 | 47 | private extension Array where Element == FeedImage { 48 | func toLocal() -> [LocalFeedImage] { 49 | return map { LocalFeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.url) } 50 | } 51 | } 52 | 53 | private extension Array where Element == LocalFeedImage { 54 | func toModels() -> [FeedImage] { 55 | return map { FeedImage(id: $0.id, description: $0.description, location: $0.location, url: $0.url) } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedCache { 8 | func save(_ feed: [FeedImage]) throws 9 | } 10 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeed/Feed Feature/FeedImageDataCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataCache { 8 | func save(_ data: Data, for url: URL) throws 9 | } 10 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Feature/FeedImageDataLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol FeedImageDataLoader { 8 | func loadImageData(from url: URL) throws -> Data 9 | } 10 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeed/Feed Presentation/FeedImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class FeedPresenter { 8 | public static var title: String { 9 | NSLocalizedString("FEED_VIEW_TITLE", 10 | tableName: "Feed", 11 | bundle: Bundle(for: FeedPresenter.self), 12 | comment: "Title for the feed view") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/el.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "Το Feed μου"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/en.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "My Feed"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Feed Presentation/pt-BR.lproj/Feed.strings: -------------------------------------------------------------------------------- 1 | 2 | "FEED_VIEW_TITLE" = "Meu Feed"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum ImageCommentsEndpoint { 8 | case get(UUID) 9 | 10 | public func url(baseURL: URL) -> URL { 11 | switch self { 12 | case let .get(id): 13 | return baseURL.appendingPathComponent("/v1/image/\(id)/comments") 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public final class ImageCommentsMapper { 8 | private struct Root: Decodable { 9 | private let items: [Item] 10 | 11 | private struct Item: Decodable { 12 | let id: UUID 13 | let message: String 14 | let created_at: Date 15 | let author: Author 16 | } 17 | 18 | private struct Author: Decodable { 19 | let username: String 20 | } 21 | 22 | var comments: [ImageComment] { 23 | items.map { ImageComment(id: $0.id, message: $0.message, createdAt: $0.created_at, username: $0.author.username) } 24 | } 25 | } 26 | 27 | public enum Error: Swift.Error { 28 | case invalidData 29 | } 30 | 31 | public static func map(_ data: Data, from response: HTTPURLResponse) throws -> [ImageComment] { 32 | let decoder = JSONDecoder() 33 | decoder.dateDecodingStrategy = .iso8601 34 | 35 | guard isOK(response), let root = try? decoder.decode(Root.self, from: data) else { 36 | throw Error.invalidData 37 | } 38 | 39 | return root.comments 40 | } 41 | 42 | private static func isOK(_ response: HTTPURLResponse) -> Bool { 43 | (200...299).contains(response.statusCode) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct ImageComment: Equatable { 8 | public let id: UUID 9 | public let message: String 10 | public let createdAt: Date 11 | public let username: String 12 | 13 | public init(id: UUID, message: String, createdAt: Date, username: String) { 14 | self.id = id 15 | self.message = message 16 | self.createdAt = createdAt 17 | self.username = username 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct ImageCommentsViewModel { 8 | public let comments: [ImageCommentViewModel] 9 | } 10 | 11 | public struct ImageCommentViewModel: Hashable { 12 | public let message: String 13 | public let date: String 14 | public let username: String 15 | 16 | public init(message: String, date: String, username: String) { 17 | self.message = message 18 | self.date = date 19 | self.username = username 20 | } 21 | } 22 | 23 | public final class ImageCommentsPresenter { 24 | public static var title: String { 25 | NSLocalizedString("IMAGE_COMMENTS_VIEW_TITLE", 26 | tableName: "ImageComments", 27 | bundle: Bundle(for: Self.self), 28 | comment: "Title for the image comments view") 29 | } 30 | 31 | public static func map( 32 | _ comments: [ImageComment], 33 | currentDate: Date = Date(), 34 | calendar: Calendar = .current, 35 | locale: Locale = .current 36 | ) -> ImageCommentsViewModel { 37 | let formatter = RelativeDateTimeFormatter() 38 | formatter.calendar = calendar 39 | formatter.locale = locale 40 | 41 | return ImageCommentsViewModel(comments: comments.map { comment in 42 | ImageCommentViewModel( 43 | message: comment.message, 44 | date: formatter.localizedString(for: comment.createdAt, relativeTo: currentDate), 45 | username: comment.username) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/el.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "Σχόλια"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/en.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "Comments"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Image Comments Presentation/pt-BR.lproj/ImageComments.strings: -------------------------------------------------------------------------------- 1 | 2 | "IMAGE_COMMENTS_VIEW_TITLE" = "Comentários"; 3 | -------------------------------------------------------------------------------- /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 © Essential Developer. All rights reserved. 23 | 24 | 25 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/Shared API/HTTPClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeed/Shared API/Paginated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public struct Paginated { 8 | public typealias LoadMoreCompletion = (Result) -> Void 9 | 10 | public let items: [Item] 11 | public let loadMore: ((@escaping LoadMoreCompletion) -> Void)? 12 | 13 | public init(items: [Item], loadMore: ((@escaping LoadMoreCompletion) -> Void)? = nil) { 14 | self.items = items 15 | self.loadMore = loadMore 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol ResourceView { 8 | associatedtype ResourceViewModel 9 | 10 | func display(_ viewModel: ResourceViewModel) 11 | } 12 | 13 | public final class LoadResourcePresenter { 14 | public typealias Mapper = (Resource) throws -> View.ResourceViewModel 15 | 16 | private let resourceView: View 17 | private let loadingView: ResourceLoadingView 18 | private let errorView: ResourceErrorView 19 | private let mapper: Mapper 20 | 21 | public static var loadError: String { 22 | NSLocalizedString("GENERIC_CONNECTION_ERROR", 23 | tableName: "Shared", 24 | bundle: Bundle(for: Self.self), 25 | comment: "Error message displayed when we can't load the resource from the server") 26 | } 27 | 28 | public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView, mapper: @escaping Mapper) { 29 | self.resourceView = resourceView 30 | self.loadingView = loadingView 31 | self.errorView = errorView 32 | self.mapper = mapper 33 | } 34 | 35 | public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView) where Resource == View.ResourceViewModel { 36 | self.resourceView = resourceView 37 | self.loadingView = loadingView 38 | self.errorView = errorView 39 | self.mapper = { $0 } 40 | } 41 | 42 | public func didStartLoading() { 43 | errorView.display(.noError) 44 | loadingView.display(ResourceLoadingViewModel(isLoading: true)) 45 | } 46 | 47 | public func didFinishLoading(with resource: Resource) { 48 | do { 49 | resourceView.display(try mapper(resource)) 50 | loadingView.display(ResourceLoadingViewModel(isLoading: false)) 51 | } catch { 52 | didFinishLoading(with: error) 53 | } 54 | } 55 | 56 | public func didFinishLoading(with error: Error) { 57 | errorView.display(.error(message: Self.loadError)) 58 | loadingView.display(ResourceLoadingViewModel(isLoading: false)) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol ResourceErrorView { 8 | func display(_ viewModel: ResourceErrorViewModel) 9 | } 10 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public protocol ResourceLoadingView { 8 | func display(_ viewModel: ResourceLoadingViewModel) 9 | } 10 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | public struct ResourceLoadingViewModel { 6 | public let isLoading: Bool 7 | } 8 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/el.lproj/Shared.strings: -------------------------------------------------------------------------------- 1 | 2 | "GENERIC_CONNECTION_ERROR" = "Δεν ήταν δυνατή η σύνδεση στο διακομιστή"; 3 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeed/Shared Presentation/en.lproj/Shared.strings: -------------------------------------------------------------------------------- 1 | 2 | "GENERIC_CONNECTION_ERROR" = "Couldn't connect to server"; 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/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 API/FeedEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedEndpointTests: XCTestCase { 9 | 10 | func test_feed_endpointURL() { 11 | let baseURL = URL(string: "http://base-url.com")! 12 | 13 | let received = FeedEndpoint.get().url(baseURL: baseURL) 14 | 15 | XCTAssertEqual(received.scheme, "http", "scheme") 16 | XCTAssertEqual(received.host, "base-url.com", "host") 17 | XCTAssertEqual(received.path, "/v1/feed", "path") 18 | XCTAssertEqual(received.query, "limit=10", "query") 19 | } 20 | 21 | func test_feed_endpointURLAfterGivenImage() { 22 | let image = uniqueImage() 23 | let baseURL = URL(string: "http://base-url.com")! 24 | 25 | let received = FeedEndpoint.get(after: image).url(baseURL: baseURL) 26 | 27 | XCTAssertEqual(received.scheme, "http", "scheme") 28 | XCTAssertEqual(received.host, "base-url.com", "host") 29 | XCTAssertEqual(received.path, "/v1/feed", "path") 30 | XCTAssertEqual(received.query?.contains("limit=10"), true, "limit query param") 31 | XCTAssertEqual(received.query?.contains("after_id=\(image.id)"), true, "after_id query param") 32 | 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class FeedImageDataMapperTests: XCTestCase { 9 | 10 | func test_map_throwsErrorOnNon200HTTPResponse() throws { 11 | let samples = [199, 201, 300, 400, 500] 12 | 13 | try samples.forEach { code in 14 | XCTAssertThrowsError( 15 | try FeedImageDataMapper.map(anyData(), from: HTTPURLResponse(statusCode: code)) 16 | ) 17 | } 18 | } 19 | 20 | func test_map_deliversInvalidDataErrorOn200HTTPResponseWithEmptyData() { 21 | let emptyData = Data() 22 | 23 | XCTAssertThrowsError( 24 | try FeedImageDataMapper.map(emptyData, from: HTTPURLResponse(statusCode: 200)) 25 | ) 26 | } 27 | 28 | func test_map_deliversReceivedNonEmptyDataOn200HTTPResponse() throws { 29 | let nonEmptyData = Data("non-empty data".utf8) 30 | 31 | let result = try FeedImageDataMapper.map(nonEmptyData, from: HTTPURLResponse(statusCode: 200)) 32 | 33 | XCTAssertEqual(result, nonEmptyData) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 json = makeItemsJSON([]) 12 | let samples = [199, 201, 300, 400, 500] 13 | 14 | try samples.forEach { code in 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 | 24 | XCTAssertThrowsError( 25 | try FeedItemsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: 200)) 26 | ) 27 | } 28 | 29 | func test_map_deliversNoItemsOn200HTTPResponseWithEmptyJSONList() throws { 30 | let emptyListJSON = makeItemsJSON([]) 31 | 32 | let result = try FeedItemsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: 200)) 33 | 34 | XCTAssertEqual(result, []) 35 | } 36 | 37 | func test_map_deliversItemsOn200HTTPResponseWithJSONItems() throws { 38 | let item1 = makeItem( 39 | id: UUID(), 40 | imageURL: URL(string: "http://a-url.com")!) 41 | 42 | let item2 = makeItem( 43 | id: UUID(), 44 | description: "a description", 45 | location: "a location", 46 | imageURL: URL(string: "http://another-url.com")!) 47 | 48 | let json = makeItemsJSON([item1.json, item2.json]) 49 | 50 | let result = try FeedItemsMapper.map(json, from: HTTPURLResponse(statusCode: 200)) 51 | 52 | XCTAssertEqual(result, [item1.model, item2.model]) 53 | } 54 | 55 | // MARK: - Helpers 56 | 57 | private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedImage, json: [String: Any]) { 58 | let item = FeedImage(id: id, description: description, location: location, url: imageURL) 59 | 60 | let json = [ 61 | "id": id.uuidString, 62 | "description": description, 63 | "location": location, 64 | "image": imageURL.absoluteString 65 | ].compactMapValues { $0 } 66 | 67 | return (item, json) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | try? sut.save(data, for: url) 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 | // MARK: - Helpers 44 | 45 | private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { 46 | let store = FeedImageDataStoreSpy() 47 | let sut = LocalFeedImageDataLoader(store: store) 48 | trackForMemoryLeaks(store, file: file, line: line) 49 | trackForMemoryLeaks(sut, file: file, line: line) 50 | return (sut, store) 51 | } 52 | 53 | private func failed() -> Result { 54 | return .failure(LocalFeedImageDataLoader.SaveError.failed) 55 | } 56 | 57 | private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: Result, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { 58 | action() 59 | 60 | let receivedResult = Result { try sut.save(anyData(), for: anyURL()) } 61 | 62 | switch (receivedResult, expectedResult) { 63 | case (.success, .success): 64 | break 65 | 66 | case (.failure(let receivedError as LocalFeedImageDataLoader.SaveError), 67 | .failure(let expectedError as LocalFeedImageDataLoader.SaveError)): 68 | XCTAssertEqual(receivedError, expectedError, file: file, line: line) 69 | 70 | default: 71 | XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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_doesNotRequestCacheInsertionOnDeletionError() { 17 | let (sut, store) = makeSUT() 18 | let deletionError = anyNSError() 19 | store.completeDeletion(with: deletionError) 20 | 21 | try? sut.save(uniqueImageFeed().models) 22 | 23 | XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) 24 | } 25 | 26 | func test_save_requestsNewCacheInsertionWithTimestampOnSuccessfulDeletion() { 27 | let timestamp = Date() 28 | let feed = uniqueImageFeed() 29 | let (sut, store) = makeSUT(currentDate: { timestamp }) 30 | store.completeDeletionSuccessfully() 31 | 32 | try? sut.save(feed.models) 33 | 34 | XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(feed.local, timestamp)]) 35 | } 36 | 37 | func test_save_failsOnDeletionError() { 38 | let (sut, store) = makeSUT() 39 | let deletionError = anyNSError() 40 | 41 | expect(sut, toCompleteWithError: deletionError, when: { 42 | store.completeDeletion(with: deletionError) 43 | }) 44 | } 45 | 46 | func test_save_failsOnInsertionError() { 47 | let (sut, store) = makeSUT() 48 | let insertionError = anyNSError() 49 | 50 | expect(sut, toCompleteWithError: insertionError, when: { 51 | store.completeDeletionSuccessfully() 52 | store.completeInsertion(with: insertionError) 53 | }) 54 | } 55 | 56 | func test_save_succeedsOnSuccessfulCacheInsertion() { 57 | let (sut, store) = makeSUT() 58 | 59 | expect(sut, toCompleteWithError: nil, when: { 60 | store.completeDeletionSuccessfully() 61 | store.completeInsertionSuccessfully() 62 | }) 63 | } 64 | 65 | // MARK: - Helpers 66 | 67 | private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #filePath, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { 68 | let store = FeedStoreSpy() 69 | let sut = LocalFeedLoader(store: store, currentDate: currentDate) 70 | trackForMemoryLeaks(store, file: file, line: line) 71 | trackForMemoryLeaks(sut, file: file, line: line) 72 | return (sut, store) 73 | } 74 | 75 | private func expect(_ sut: LocalFeedLoader, toCompleteWithError expectedError: NSError?, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { 76 | action() 77 | 78 | var receivedError: NSError? 79 | 80 | do { 81 | try sut.save(uniqueImageFeed().models) 82 | } catch { 83 | receivedError = error as NSError? 84 | } 85 | 86 | XCTAssertEqual(receivedError, expectedError, file: file, line: line) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class CoreDataFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { 9 | 10 | func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { 11 | try makeSUT { sut, imageDataURL in 12 | self.assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut, imageDataURL: imageDataURL) 13 | } 14 | } 15 | 16 | func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { 17 | try makeSUT { sut, imageDataURL in 18 | self.assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut, imageDataURL: imageDataURL) 19 | } 20 | } 21 | 22 | func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { 23 | try makeSUT { sut, imageDataURL in 24 | self.assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut, imageDataURL: imageDataURL) 25 | } 26 | } 27 | 28 | func test_retrieveImageData_deliversLastInsertedValue() throws { 29 | try makeSUT { sut, imageDataURL in 30 | self.assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut, imageDataURL: imageDataURL) 31 | } 32 | } 33 | 34 | // - MARK: Helpers 35 | 36 | private func makeSUT(_ test: @escaping (CoreDataFeedStore, URL) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { 37 | let storeURL = URL(fileURLWithPath: "/dev/null") 38 | let sut = try CoreDataFeedStore(storeURL: storeURL) 39 | trackForMemoryLeaks(sut, file: file, line: line) 40 | 41 | let exp = expectation(description: "wait for operation") 42 | sut.perform { 43 | let imageDataURL = URL(string: "http://a-url.com")! 44 | insertFeedImage(with: imageDataURL, into: sut, file: file, line: line) 45 | test(sut, imageDataURL) 46 | exp.fulfill() 47 | } 48 | wait(for: [exp], timeout: 0.1) 49 | } 50 | 51 | } 52 | 53 | private func insertFeedImage(with url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { 54 | do { 55 | let image = LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) 56 | try sut.insert([image], timestamp: Date()) 57 | } catch { 58 | XCTFail("Failed to insert feed image with URL \(url) - error: \(error)", file: file, line: line) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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() throws { 11 | try makeSUT { sut in 12 | self.assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) 13 | } 14 | } 15 | 16 | func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { 17 | try makeSUT { sut in 18 | self.assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) 19 | } 20 | } 21 | 22 | func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { 23 | try makeSUT { sut in 24 | self.assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) 25 | } 26 | } 27 | 28 | func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { 29 | try makeSUT { sut in 30 | self.assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) 31 | } 32 | } 33 | 34 | func test_insert_deliversNoErrorOnEmptyCache() throws { 35 | try makeSUT { sut in 36 | self.assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) 37 | } 38 | } 39 | 40 | func test_insert_deliversNoErrorOnNonEmptyCache() throws { 41 | try makeSUT { sut in 42 | self.assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) 43 | } 44 | } 45 | 46 | func test_insert_overridesPreviouslyInsertedCacheValues() throws { 47 | try makeSUT { sut in 48 | self.assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) 49 | } 50 | } 51 | 52 | func test_delete_deliversNoErrorOnEmptyCache() throws { 53 | try makeSUT { sut in 54 | self.assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) 55 | } 56 | } 57 | 58 | func test_delete_hasNoSideEffectsOnEmptyCache() throws { 59 | try makeSUT { sut in 60 | self.assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) 61 | } 62 | } 63 | 64 | func test_delete_deliversNoErrorOnNonEmptyCache() throws { 65 | try makeSUT { sut in 66 | self.assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) 67 | } 68 | } 69 | 70 | func test_delete_emptiesPreviouslyInsertedCache() throws { 71 | try makeSUT { sut in 72 | self.assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) 73 | } 74 | } 75 | 76 | // - MARK: Helpers 77 | 78 | private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { 79 | let storeURL = URL(fileURLWithPath: "/dev/null") 80 | let sut = try CoreDataFeedStore(storeURL: storeURL) 81 | trackForMemoryLeaks(sut, file: file, line: line) 82 | 83 | let exp = expectation(description: "wait for operation") 84 | sut.perform { 85 | test(sut) 86 | exp.fulfill() 87 | } 88 | wait(for: [exp], timeout: 0.1) 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol FeedImageDataStoreSpecs { 8 | func test_retrieveImageData_deliversNotFoundWhenEmpty() throws 9 | func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws 10 | func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws 11 | func test_retrieveImageData_deliversLastInsertedValue() throws 12 | } 13 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import Foundation 7 | import EssentialFeed 8 | 9 | extension FeedImageDataStoreSpecs where Self: XCTestCase { 10 | 11 | func assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache( 12 | on sut: FeedImageDataStore, 13 | imageDataURL: URL = anyURL(), 14 | file: StaticString = #filePath, 15 | line: UInt = #line 16 | ) { 17 | expect(sut, toCompleteRetrievalWith: notFound(), for: imageDataURL, file: file, line: line) 18 | } 19 | 20 | func assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch( 21 | on sut: FeedImageDataStore, 22 | imageDataURL: URL = anyURL(), 23 | file: StaticString = #filePath, 24 | line: UInt = #line 25 | ) { 26 | let nonMatchingURL = URL(string: "http://a-non-matching-url.com")! 27 | 28 | insert(anyData(), for: imageDataURL, into: sut, file: file, line: line) 29 | 30 | expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL, file: file, line: line) 31 | } 32 | 33 | func assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL( 34 | on sut: FeedImageDataStore, 35 | imageDataURL: URL = anyURL(), 36 | file: StaticString = #filePath, 37 | line: UInt = #line 38 | ) { 39 | let storedData = anyData() 40 | 41 | insert(storedData, for: imageDataURL, into: sut, file: file, line: line) 42 | 43 | expect(sut, toCompleteRetrievalWith: found(storedData), for: imageDataURL, file: file, line: line) 44 | } 45 | 46 | func assertThatRetrieveImageDataDeliversLastInsertedValueForURL( 47 | on sut: FeedImageDataStore, 48 | imageDataURL: URL = anyURL(), 49 | file: StaticString = #filePath, 50 | line: UInt = #line 51 | ) { 52 | let firstStoredData = Data("first".utf8) 53 | let lastStoredData = Data("last".utf8) 54 | 55 | insert(firstStoredData, for: imageDataURL, into: sut, file: file, line: line) 56 | insert(lastStoredData, for: imageDataURL, into: sut, file: file, line: line) 57 | 58 | expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: imageDataURL, file: file, line: line) 59 | } 60 | 61 | } 62 | 63 | extension FeedImageDataStoreSpecs where Self: XCTestCase { 64 | 65 | func notFound() -> Result { 66 | .success(.none) 67 | } 68 | 69 | func found(_ data: Data) -> Result { 70 | .success(data) 71 | } 72 | 73 | func expect(_ sut: FeedImageDataStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { 74 | let receivedResult = Result { try sut.retrieve(dataForURL: url) } 75 | 76 | switch (receivedResult, expectedResult) { 77 | case let (.success( receivedData), .success(expectedData)): 78 | XCTAssertEqual(receivedData, expectedData, file: file, line: line) 79 | 80 | default: 81 | XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) 82 | } 83 | } 84 | 85 | func insert(_ data: Data, for url: URL, into sut: FeedImageDataStore, file: StaticString = #filePath, line: UInt = #line) { 86 | do { 87 | try sut.insert(data, for: url) 88 | } catch { 89 | XCTFail("Failed to insert image data: \(data) - error: \(error)", file: file, line: line) 90 | } 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/FeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol FeedStoreSpecs { 8 | func test_retrieve_deliversEmptyOnEmptyCache() throws 9 | func test_retrieve_hasNoSideEffectsOnEmptyCache() throws 10 | func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws 11 | func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws 12 | 13 | func test_insert_deliversNoErrorOnEmptyCache() throws 14 | func test_insert_deliversNoErrorOnNonEmptyCache() throws 15 | func test_insert_overridesPreviouslyInsertedCacheValues() throws 16 | 17 | func test_delete_deliversNoErrorOnEmptyCache() throws 18 | func test_delete_hasNoSideEffectsOnEmptyCache() throws 19 | func test_delete_deliversNoErrorOnNonEmptyCache() throws 20 | func test_delete_emptiesPreviouslyInsertedCache() throws 21 | } 22 | 23 | protocol FailableRetrieveFeedStoreSpecs: FeedStoreSpecs { 24 | func test_retrieve_deliversFailureOnRetrievalError() throws 25 | func test_retrieve_hasNoSideEffectsOnFailure() throws 26 | } 27 | 28 | protocol FailableInsertFeedStoreSpecs: FeedStoreSpecs { 29 | func test_insert_deliversErrorOnInsertionError() throws 30 | func test_insert_hasNoSideEffectsOnInsertionError() throws 31 | } 32 | 33 | protocol FailableDeleteFeedStoreSpecs: FeedStoreSpecs { 34 | func test_delete_deliversErrorOnDeletionError() throws 35 | func test_delete_hasNoSideEffectsOnDeletionError() throws 36 | } 37 | 38 | typealias FailableFeedStoreSpecs = FailableRetrieveFeedStoreSpecs & FailableInsertFeedStoreSpecs & FailableDeleteFeedStoreSpecs 39 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableDeleteFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 = #filePath, 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 = #filePath, line: UInt = #line) { 16 | deleteCache(from: sut) 17 | 18 | expect(sut, toRetrieve: .success(.none), file: file, line: line) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableInsertFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 = #filePath, 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 = #filePath, line: UInt = #line) { 16 | insert((uniqueImageFeed().local, Date()), to: sut) 17 | 18 | expect(sut, toRetrieve: .success(.none), file: file, line: line) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FailableRetrieveFeedStoreSpecs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 = #filePath, line: UInt = #line) { 10 | expect(sut, toRetrieve: .failure(anyNSError()), file: file, line: line) 11 | } 12 | 13 | func assertThatRetrieveHasNoSideEffectsOnFailure(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { 14 | expect(sut, toRetrieveTwice: .failure(anyNSError()), file: file, line: line) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeedTests/Feed Cache/Helpers/FeedImageDataStoreSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 retrievalResult: Result? 16 | private var insertionResult: Result? 17 | 18 | func insert(_ data: Data, for url: URL) throws { 19 | receivedMessages.append(.insert(data: data, for: url)) 20 | try insertionResult?.get() 21 | } 22 | 23 | func retrieve(dataForURL url: URL) throws -> Data? { 24 | receivedMessages.append(.retrieve(dataFor: url)) 25 | return try retrievalResult?.get() 26 | } 27 | 28 | func completeRetrieval(with error: Error) { 29 | retrievalResult = .failure(error) 30 | } 31 | 32 | func completeRetrieval(with data: Data?) { 33 | retrievalResult = .success(data) 34 | } 35 | 36 | func completeInsertion(with error: Error) { 37 | insertionResult = .failure(error) 38 | } 39 | 40 | func completeInsertionSuccessfully() { 41 | insertionResult = .success(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 deletionResult: Result? 18 | private var insertionResult: Result? 19 | private var retrievalResult: Result? 20 | 21 | func deleteCachedFeed() throws { 22 | receivedMessages.append(.deleteCachedFeed) 23 | try deletionResult?.get() 24 | } 25 | 26 | func completeDeletion(with error: Error) { 27 | deletionResult = .failure(error) 28 | } 29 | 30 | func completeDeletionSuccessfully() { 31 | deletionResult = .success(()) 32 | } 33 | 34 | func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { 35 | receivedMessages.append(.insert(feed, timestamp)) 36 | try insertionResult?.get() 37 | } 38 | 39 | func completeInsertion(with error: Error) { 40 | insertionResult = .failure(error) 41 | } 42 | 43 | func completeInsertionSuccessfully() { 44 | insertionResult = .success(()) 45 | } 46 | 47 | func retrieve() throws -> CachedFeed? { 48 | receivedMessages.append(.retrieve) 49 | return try retrievalResult?.get() 50 | } 51 | 52 | func completeRetrieval(with error: Error) { 53 | retrievalResult = .failure(error) 54 | } 55 | 56 | func completeRetrievalWithEmptyCache() { 57 | retrievalResult = .success(.none) 58 | } 59 | 60 | func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date) { 61 | retrievalResult = .success(CachedFeed(feed: feed, timestamp: timestamp)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class InMemoryFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { 9 | 10 | func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { 11 | let sut = makeSUT() 12 | 13 | assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut) 14 | } 15 | 16 | func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { 17 | let sut = makeSUT() 18 | 19 | assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut) 20 | } 21 | 22 | func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { 23 | let sut = makeSUT() 24 | 25 | assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut) 26 | } 27 | 28 | func test_retrieveImageData_deliversLastInsertedValue() throws { 29 | let sut = makeSUT() 30 | 31 | assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut) 32 | } 33 | 34 | // - MARK: Helpers 35 | 36 | private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { 37 | let sut = InMemoryFeedStore() 38 | trackForMemoryLeaks(sut, file: file, line: line) 39 | return sut 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class InMemoryFeedStoreTests: XCTestCase, FeedStoreSpecs { 9 | 10 | func test_retrieve_deliversEmptyOnEmptyCache() throws { 11 | let sut = makeSUT() 12 | 13 | assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) 14 | } 15 | 16 | func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { 17 | let sut = makeSUT() 18 | 19 | assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) 20 | } 21 | 22 | func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { 23 | let sut = makeSUT() 24 | 25 | assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) 26 | } 27 | 28 | func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { 29 | let sut = makeSUT() 30 | 31 | assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) 32 | } 33 | 34 | func test_insert_deliversNoErrorOnEmptyCache() throws { 35 | let sut = makeSUT() 36 | 37 | assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) 38 | } 39 | 40 | func test_insert_deliversNoErrorOnNonEmptyCache() throws { 41 | let sut = makeSUT() 42 | 43 | assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) 44 | } 45 | 46 | func test_insert_overridesPreviouslyInsertedCacheValues() throws { 47 | let sut = makeSUT() 48 | 49 | assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) 50 | } 51 | 52 | func test_delete_deliversNoErrorOnEmptyCache() throws { 53 | let sut = makeSUT() 54 | 55 | assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) 56 | } 57 | 58 | func test_delete_hasNoSideEffectsOnEmptyCache() throws { 59 | let sut = makeSUT() 60 | 61 | assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) 62 | } 63 | 64 | func test_delete_deliversNoErrorOnNonEmptyCache() throws { 65 | let sut = makeSUT() 66 | 67 | assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) 68 | } 69 | 70 | func test_delete_emptiesPreviouslyInsertedCache() throws { 71 | let sut = makeSUT() 72 | 73 | assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) 74 | } 75 | 76 | // - MARK: Helpers 77 | 78 | private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { 79 | let sut = InMemoryFeedStore() 80 | trackForMemoryLeaks(sut, file: file, line: line) 81 | return sut 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | _ = try? sut.loadImageData(from: url) 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 | // MARK: - Helpers 52 | 53 | private func makeSUT(currentDate: @escaping () -> Date = Date.init, file: StaticString = #filePath, line: UInt = #line) -> (sut: LocalFeedImageDataLoader, store: FeedImageDataStoreSpy) { 54 | let store = FeedImageDataStoreSpy() 55 | let sut = LocalFeedImageDataLoader(store: store) 56 | trackForMemoryLeaks(store, file: file, line: line) 57 | trackForMemoryLeaks(sut, file: file, line: line) 58 | return (sut, store) 59 | } 60 | 61 | private func failed() -> Result { 62 | return .failure(LocalFeedImageDataLoader.LoadError.failed) 63 | } 64 | 65 | private func notFound() -> Result { 66 | return .failure(LocalFeedImageDataLoader.LoadError.notFound) 67 | } 68 | 69 | private func expect(_ sut: LocalFeedImageDataLoader, toCompleteWith expectedResult: Result, when action: () -> Void, file: StaticString = #filePath, line: UInt = #line) { 70 | action() 71 | 72 | let receivedResult = Result { try sut.loadImageData(from: anyURL()) } 73 | 74 | switch (receivedResult, expectedResult) { 75 | case let (.success(receivedData), .success(expectedData)): 76 | XCTAssertEqual(receivedData, expectedData, file: file, line: line) 77 | 78 | case (.failure(let receivedError as LocalFeedImageDataLoader.LoadError), 79 | .failure(let expectedError as LocalFeedImageDataLoader.LoadError)): 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 | 87 | } 88 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | } 18 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | // MARK: - Helpers 15 | 16 | private func localized(_ key: String, file: StaticString = #filePath, line: UInt = #line) -> String { 17 | let table = "Feed" 18 | let bundle = Bundle(for: FeedPresenter.self) 19 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 20 | if value == key { 21 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 22 | } 23 | return value 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | func assertLocalizedKeyAndValuesExist(in presentationBundle: Bundle, _ table: String, file: StaticString = #filePath, line: UInt = #line) { 8 | let localizationBundles = allLocalizationBundles(in: presentationBundle, file: file, line: line) 9 | let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table, file: file, line: line) 10 | 11 | localizationBundles.forEach { (bundle, localization) in 12 | localizedStringKeys.forEach { key in 13 | let localizedString = bundle.localizedString(forKey: key, value: nil, table: table) 14 | 15 | if localizedString == key { 16 | let language = Locale.current.localizedString(forLanguageCode: localization) ?? "" 17 | 18 | XCTFail("Missing \(language) (\(localization)) localized string for key: '\(key)' in table: '\(table)'", file: file, line: line) 19 | } 20 | } 21 | } 22 | } 23 | 24 | private typealias LocalizedBundle = (bundle: Bundle, localization: String) 25 | 26 | private func allLocalizationBundles(in bundle: Bundle, file: StaticString = #filePath, line: UInt = #line) -> [LocalizedBundle] { 27 | return bundle.localizations.compactMap { localization in 28 | guard 29 | let path = bundle.path(forResource: localization, ofType: "lproj"), 30 | let localizedBundle = Bundle(path: path) 31 | else { 32 | XCTFail("Couldn't find bundle for localization: \(localization)", file: file, line: line) 33 | return nil 34 | } 35 | 36 | return (localizedBundle, localization) 37 | } 38 | } 39 | 40 | private func allLocalizedStringKeys(in bundles: [LocalizedBundle], table: String, file: StaticString = #filePath, line: UInt = #line) -> Set { 41 | return bundles.reduce([]) { (acc, current) in 42 | guard 43 | let path = current.bundle.path(forResource: table, ofType: "strings"), 44 | let strings = NSDictionary(contentsOfFile: path), 45 | let keys = strings.allKeys as? [String] 46 | else { 47 | XCTFail("Couldn't load localized strings for localization: \(current.localization)", file: file, line: line) 48 | return acc 49 | } 50 | 51 | return acc.union(Set(keys)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | extension XCTestCase { 8 | func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, 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/Image Comments API/ImageCommentsEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class ImageCommentsEndpointTests: XCTestCase { 9 | 10 | func test_imageComments_endpointURL() { 11 | let imageID = UUID(uuidString: "2239CBA2-CB35-4392-ADC0-24A37D38E010")! 12 | let baseURL = URL(string: "http://base-url.com")! 13 | 14 | let received = ImageCommentsEndpoint.get(imageID).url(baseURL: baseURL) 15 | let expected = URL(string: "http://base-url.com/v1/image/2239CBA2-CB35-4392-ADC0-24A37D38E010/comments")! 16 | 17 | XCTAssertEqual(received, expected) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class ImageCommentsMapperTests: XCTestCase { 9 | 10 | func test_map_throwsErrorOnNon2xxHTTPResponse() throws { 11 | let json = makeItemsJSON([]) 12 | let samples = [199, 150, 300, 400, 500] 13 | 14 | try samples.forEach { code in 15 | XCTAssertThrowsError( 16 | try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 17 | ) 18 | } 19 | } 20 | 21 | func test_map_throwsErrorOn2xxHTTPResponseWithInvalidJSON() throws { 22 | let invalidJSON = Data("invalid json".utf8) 23 | let samples = [200, 201, 250, 280, 299] 24 | 25 | try samples.forEach { code in 26 | XCTAssertThrowsError( 27 | try ImageCommentsMapper.map(invalidJSON, from: HTTPURLResponse(statusCode: code)) 28 | ) 29 | } 30 | } 31 | 32 | func test_map_deliversNoItemsOn2xxHTTPResponseWithEmptyJSONList() throws { 33 | let emptyListJSON = makeItemsJSON([]) 34 | let samples = [200, 201, 250, 280, 299] 35 | 36 | try samples.forEach { code in 37 | let result = try ImageCommentsMapper.map(emptyListJSON, from: HTTPURLResponse(statusCode: code)) 38 | 39 | XCTAssertEqual(result, []) 40 | } 41 | } 42 | 43 | func test_map_deliversItemsOn2xxHTTPResponseWithJSONItems() throws { 44 | let item1 = makeItem( 45 | id: UUID(), 46 | message: "a message", 47 | createdAt: (Date(timeIntervalSince1970: 1598627222), "2020-08-28T15:07:02+00:00"), 48 | username: "a username") 49 | 50 | let item2 = makeItem( 51 | id: UUID(), 52 | message: "another message", 53 | createdAt: (Date(timeIntervalSince1970: 1577881882), "2020-01-01T12:31:22+00:00"), 54 | username: "another username") 55 | 56 | let json = makeItemsJSON([item1.json, item2.json]) 57 | let samples = [200, 201, 250, 280, 299] 58 | 59 | try samples.forEach { code in 60 | let result = try ImageCommentsMapper.map(json, from: HTTPURLResponse(statusCode: code)) 61 | 62 | XCTAssertEqual(result, [item1.model, item2.model]) 63 | } 64 | } 65 | 66 | // MARK: - Helpers 67 | 68 | private func makeItem(id: UUID, message: String, createdAt: (date: Date, iso8601String: String), username: String) -> (model: ImageComment, json: [String: Any]) { 69 | let item = ImageComment(id: id, message: message, createdAt: createdAt.date, username: username) 70 | 71 | let json: [String: Any] = [ 72 | "id": id.uuidString, 73 | "message": message, 74 | "created_at": createdAt.iso8601String, 75 | "author": [ 76 | "username": username 77 | ] 78 | ] 79 | 80 | return (item, json) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class ImageCommentsLocalizationTests: XCTestCase { 9 | 10 | func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { 11 | let table = "ImageComments" 12 | let bundle = Bundle(for: ImageCommentsPresenter.self) 13 | 14 | assertLocalizedKeyAndValuesExist(in: bundle, table) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class ImageCommentsPresenterTests: XCTestCase { 9 | 10 | func test_title_isLocalized() { 11 | XCTAssertEqual(ImageCommentsPresenter.title, localized("IMAGE_COMMENTS_VIEW_TITLE")) 12 | } 13 | 14 | func test_map_createsViewModels() { 15 | let now = Date() 16 | let calendar = Calendar(identifier: .gregorian) 17 | let locale = Locale(identifier: "en_US_POSIX") 18 | 19 | let comments = [ 20 | ImageComment( 21 | id: UUID(), 22 | message: "a message", 23 | createdAt: now.adding(minutes: -5, calendar: calendar), 24 | username: "a username"), 25 | ImageComment( 26 | id: UUID(), 27 | message: "another message", 28 | createdAt: now.adding(days: -1, calendar: calendar), 29 | username: "another username") 30 | ] 31 | 32 | let viewModel = ImageCommentsPresenter.map( 33 | comments, 34 | currentDate: now, 35 | calendar: calendar, 36 | locale: locale 37 | ) 38 | 39 | XCTAssertEqual(viewModel.comments, [ 40 | ImageCommentViewModel( 41 | message: "a message", 42 | date: "5 minutes ago", 43 | username: "a username" 44 | ), 45 | ImageCommentViewModel( 46 | message: "another message", 47 | date: "1 day ago", 48 | username: "another username" 49 | ) 50 | ]) 51 | } 52 | 53 | // MARK: - Helpers 54 | 55 | private func localized(_ key: String, file: StaticString = #filePath, line: UInt = #line) -> String { 56 | let table = "ImageComments" 57 | let bundle = Bundle(for: ImageCommentsPresenter.self) 58 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 59 | if value == key { 60 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 61 | } 62 | return value 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /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/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | class URLProtocolStub: URLProtocol { 8 | private struct Stub { 9 | let onStartLoading: (URLProtocolStub) -> Void 10 | } 11 | 12 | private static var _stub: Stub? 13 | private static var stub: Stub? { 14 | get { return queue.sync { _stub } } 15 | set { queue.sync { _stub = newValue } } 16 | } 17 | 18 | private static let queue = DispatchQueue(label: "URLProtocolStub.queue") 19 | 20 | static func stub(data: Data?, response: URLResponse?, error: Error?) { 21 | stub = Stub(onStartLoading: { urlProtocol in 22 | guard let client = urlProtocol.client else { return } 23 | 24 | if let data { 25 | client.urlProtocol(urlProtocol, didLoad: data) 26 | } 27 | 28 | if let response { 29 | client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) 30 | } 31 | 32 | if let error { 33 | client.urlProtocol(urlProtocol, didFailWithError: error) 34 | } else { 35 | client.urlProtocolDidFinishLoading(urlProtocol) 36 | } 37 | }) 38 | } 39 | 40 | static func observeRequests(observer: @escaping (URLRequest) -> Void) { 41 | stub = Stub(onStartLoading: { urlProtocol in 42 | urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) 43 | 44 | observer(urlProtocol.request) 45 | }) 46 | } 47 | 48 | static func onStartLoading(observer: @escaping () -> Void) { 49 | stub = Stub(onStartLoading: { _ in observer() }) 50 | } 51 | 52 | static func removeStub() { 53 | stub = nil 54 | } 55 | 56 | override class func canInit(with request: URLRequest) -> Bool { 57 | return true 58 | } 59 | 60 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 61 | return request 62 | } 63 | 64 | override func startLoading() { 65 | URLProtocolStub.stub?.onStartLoading(self) 66 | } 67 | 68 | override func stopLoading() {} 69 | } 70 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class LoadResourcePresenterTests: XCTestCase { 9 | 10 | func test_init_doesNotSendMessagesToView() { 11 | let (_, view) = makeSUT() 12 | 13 | XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") 14 | } 15 | 16 | func test_didStartLoading_displaysNoErrorMessageAndStartsLoading() { 17 | let (sut, view) = makeSUT() 18 | 19 | sut.didStartLoading() 20 | 21 | XCTAssertEqual(view.messages, [ 22 | .display(errorMessage: .none), 23 | .display(isLoading: true) 24 | ]) 25 | } 26 | 27 | func test_didFinishLoadingResource_displaysResourceAndStopsLoading() { 28 | let (sut, view) = makeSUT(mapper: { resource in 29 | resource + " view model" 30 | }) 31 | 32 | sut.didFinishLoading(with: "resource") 33 | 34 | XCTAssertEqual(view.messages, [ 35 | .display(resourceViewModel: "resource view model"), 36 | .display(isLoading: false) 37 | ]) 38 | } 39 | 40 | func test_didFinishLoadingWithMapperError_displaysLocalizedErrorMessageAndStopsLoading() { 41 | let (sut, view) = makeSUT(mapper: { resource in 42 | throw anyNSError() 43 | }) 44 | 45 | sut.didFinishLoading(with: "resource") 46 | 47 | XCTAssertEqual(view.messages, [ 48 | .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), 49 | .display(isLoading: false) 50 | ]) 51 | } 52 | 53 | func test_didFinishLoadingWithError_displaysLocalizedErrorMessageAndStopsLoading() { 54 | let (sut, view) = makeSUT() 55 | 56 | sut.didFinishLoading(with: anyNSError()) 57 | 58 | XCTAssertEqual(view.messages, [ 59 | .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), 60 | .display(isLoading: false) 61 | ]) 62 | } 63 | 64 | // MARK: - Helpers 65 | 66 | private typealias SUT = LoadResourcePresenter 67 | 68 | private func makeSUT( 69 | mapper: @escaping SUT.Mapper = { _ in "any" }, 70 | file: StaticString = #filePath, 71 | line: UInt = #line 72 | ) -> (sut: SUT, view: ViewSpy) { 73 | let view = ViewSpy() 74 | let sut = SUT(resourceView: view, loadingView: view, errorView: view, mapper: mapper) 75 | trackForMemoryLeaks(view, file: file, line: line) 76 | trackForMemoryLeaks(sut, file: file, line: line) 77 | return (sut, view) 78 | } 79 | 80 | private func localized(_ key: String, file: StaticString = #filePath, line: UInt = #line) -> String { 81 | let table = "Shared" 82 | let bundle = Bundle(for: SUT.self) 83 | let value = bundle.localizedString(forKey: key, value: nil, table: table) 84 | if value == key { 85 | XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) 86 | } 87 | return value 88 | } 89 | 90 | private class ViewSpy: ResourceView, ResourceLoadingView, ResourceErrorView { 91 | typealias ResourceViewModel = String 92 | 93 | enum Message: Hashable { 94 | case display(errorMessage: String?) 95 | case display(isLoading: Bool) 96 | case display(resourceViewModel: String) 97 | } 98 | 99 | private(set) var messages = Set() 100 | 101 | func display(_ viewModel: ResourceErrorViewModel) { 102 | messages.insert(.display(errorMessage: viewModel.message)) 103 | } 104 | 105 | func display(_ viewModel: ResourceLoadingViewModel) { 106 | messages.insert(.display(isLoading: viewModel.isLoading)) 107 | } 108 | 109 | func display(_ viewModel: String) { 110 | messages.insert(.display(resourceViewModel: viewModel)) 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeed 7 | 8 | class SharedLocalizationTests: XCTestCase { 9 | 10 | func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { 11 | let table = "Shared" 12 | let bundle = Bundle(for: LoadResourcePresenter.self) 13 | 14 | assertLocalizedKeyAndValuesExist(in: bundle, table) 15 | } 16 | 17 | private class DummyView: ResourceView { 18 | func display(_ viewModel: Any) {} 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | public final class FeedImageCellController: NSObject { 14 | public typealias ResourceViewModel = UIImage 15 | 16 | private let viewModel: FeedImageViewModel 17 | private let delegate: FeedImageCellControllerDelegate 18 | private let selection: () -> Void 19 | private var cell: FeedImageCell? 20 | 21 | public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate, selection: @escaping () -> Void) { 22 | self.viewModel = viewModel 23 | self.delegate = delegate 24 | self.selection = selection 25 | } 26 | } 27 | 28 | extension FeedImageCellController: UITableViewDataSource, UITableViewDelegate, UITableViewDataSourcePrefetching { 29 | 30 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 31 | 1 32 | } 33 | 34 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 35 | cell = tableView.dequeueReusableCell() 36 | cell?.locationContainer.isHidden = !viewModel.hasLocation 37 | cell?.locationLabel.text = viewModel.location 38 | cell?.descriptionLabel.text = viewModel.description 39 | cell?.feedImageView.image = nil 40 | cell?.feedImageContainer.isShimmering = true 41 | cell?.feedImageRetryButton.isHidden = true 42 | cell?.onRetry = { [weak self] in 43 | self?.delegate.didRequestImage() 44 | } 45 | cell?.onReuse = { [weak self] in 46 | self?.releaseCellForReuse() 47 | } 48 | delegate.didRequestImage() 49 | return cell! 50 | } 51 | 52 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 53 | selection() 54 | } 55 | 56 | public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { 57 | self.cell = cell as? FeedImageCell 58 | delegate.didRequestImage() 59 | } 60 | 61 | public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { 62 | cancelLoad() 63 | } 64 | 65 | public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { 66 | delegate.didRequestImage() 67 | } 68 | 69 | public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { 70 | cancelLoad() 71 | } 72 | 73 | private func cancelLoad() { 74 | releaseCellForReuse() 75 | delegate.didCancelImageRequest() 76 | } 77 | 78 | private func releaseCellForReuse() { 79 | cell?.onReuse = nil 80 | cell = nil 81 | } 82 | } 83 | 84 | extension FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { 85 | public func display(_ viewModel: UIImage) { 86 | cell?.feedImageView.setImageAnimated(viewModel) 87 | } 88 | 89 | public func display(_ viewModel: ResourceLoadingViewModel) { 90 | cell?.feedImageContainer.isShimmering = viewModel.isLoading 91 | } 92 | 93 | public func display(_ viewModel: ResourceErrorViewModel) { 94 | cell?.feedImageRetryButton.isHidden = viewModel.message == nil 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | 8 | public class LoadMoreCellController: NSObject, UITableViewDataSource, UITableViewDelegate { 9 | private let cell = LoadMoreCell() 10 | private let callback: () -> Void 11 | private var offsetObserver: NSKeyValueObservation? 12 | 13 | public init(callback: @escaping () -> Void) { 14 | self.callback = callback 15 | } 16 | 17 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 18 | 1 19 | } 20 | 21 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 22 | cell.selectionStyle = .none 23 | return cell 24 | } 25 | 26 | public func tableView(_ tableView: UITableView, willDisplay: UITableViewCell, forRowAt indexPath: IndexPath) { 27 | reloadIfNeeded() 28 | 29 | offsetObserver = tableView.observe(\.contentOffset, options: .new) { [weak self] (tableView, _) in 30 | guard tableView.isDragging else { return } 31 | 32 | self?.reloadIfNeeded() 33 | } 34 | } 35 | 36 | public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { 37 | offsetObserver = nil 38 | } 39 | 40 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 41 | reloadIfNeeded() 42 | } 43 | 44 | private func reloadIfNeeded() { 45 | guard !cell.isLoading else { return } 46 | 47 | callback() 48 | } 49 | } 50 | 51 | extension LoadMoreCellController: ResourceLoadingView, ResourceErrorView { 52 | public func display(_ viewModel: ResourceErrorViewModel) { 53 | cell.message = viewModel.message 54 | } 55 | 56 | public func display(_ viewModel: ResourceLoadingViewModel) { 57 | cell.isLoading = viewModel.isLoading 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /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/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/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/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/FeedImageCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | var onReuse: (() -> Void)? 17 | 18 | @IBAction private func retryButtonTapped() { 19 | onRetry?() 20 | } 21 | 22 | public override func prepareForReuse() { 23 | super.prepareForReuse() 24 | 25 | onReuse?() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIImageView+Animations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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 | layer.mask is ShimmeringLayer 19 | } 20 | } 21 | 22 | private func startShimmering() { 23 | layer.mask = ShimmeringLayer(size: bounds.size) 24 | } 25 | 26 | private func stopShimmering() { 27 | layer.mask = nil 28 | } 29 | 30 | private class ShimmeringLayer: CAGradientLayer { 31 | private var observer: Any? 32 | 33 | convenience init(size: CGSize) { 34 | self.init() 35 | 36 | let white = UIColor.white.cgColor 37 | let alpha = UIColor.white.withAlphaComponent(0.75).cgColor 38 | 39 | colors = [alpha, white, alpha] 40 | startPoint = CGPoint(x: 0.0, y: 0.4) 41 | endPoint = CGPoint(x: 1.0, y: 0.6) 42 | locations = [0.4, 0.5, 0.6] 43 | frame = CGRect(x: -size.width, y: 0, width: size.width*3, height: size.height) 44 | 45 | let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) 46 | animation.fromValue = [0.0, 0.1, 0.2] 47 | animation.toValue = [0.8, 0.9, 1.0] 48 | animation.duration = 1.25 49 | animation.repeatCount = .infinity 50 | add(animation, forKey: "shimmer") 51 | 52 | observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in 53 | self?.add(animation, forKey: "shimmer") 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Feed UI/Views/LoadMoreCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public class LoadMoreCell: UITableViewCell { 8 | 9 | private lazy var spinner: UIActivityIndicatorView = { 10 | let spinner = UIActivityIndicatorView(style: .medium) 11 | contentView.addSubview(spinner) 12 | 13 | spinner.translatesAutoresizingMaskIntoConstraints = false 14 | NSLayoutConstraint.activate([ 15 | spinner.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), 16 | spinner.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), 17 | contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 40) 18 | ]) 19 | 20 | return spinner 21 | }() 22 | 23 | private lazy var messageLabel: UILabel = { 24 | let label = UILabel() 25 | label.textColor = .tertiaryLabel 26 | label.font = .preferredFont(forTextStyle: .footnote) 27 | label.numberOfLines = 0 28 | label.textAlignment = .center 29 | label.adjustsFontForContentSizeCategory = true 30 | contentView.addSubview(label) 31 | 32 | label.translatesAutoresizingMaskIntoConstraints = false 33 | NSLayoutConstraint.activate([ 34 | label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), 35 | contentView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8), 36 | label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), 37 | contentView.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 8), 38 | ]) 39 | 40 | return label 41 | }() 42 | 43 | public var isLoading: Bool { 44 | get { spinner.isAnimating } 45 | set { 46 | if newValue { 47 | spinner.startAnimating() 48 | } else { 49 | spinner.stopAnimating() 50 | } 51 | } 52 | } 53 | 54 | public var message: String? { 55 | get { messageLabel.text } 56 | set { messageLabel.text = newValue } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Image Comments UI/Controllers/ImageCommentCellController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | import EssentialFeed 7 | 8 | public class ImageCommentCellController: NSObject, UITableViewDataSource { 9 | private let model: ImageCommentViewModel 10 | 11 | public init(model: ImageCommentViewModel) { 12 | self.model = model 13 | } 14 | 15 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 16 | 1 17 | } 18 | 19 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 20 | let cell: ImageCommentCell = tableView.dequeueReusableCell() 21 | cell.messageLabel.text = model.message 22 | cell.usernameLabel.text = model.username 23 | cell.dateLabel.text = model.date 24 | return cell 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Image Comments UI/Views/ImageCommentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public final class ImageCommentCell: UITableViewCell { 8 | @IBOutlet private(set) public var messageLabel: UILabel! 9 | @IBOutlet private(set) public var usernameLabel: UILabel! 10 | @IBOutlet private(set) public var dateLabel: UILabel! 11 | } 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public struct CellController { 8 | let id: AnyHashable 9 | let dataSource: UITableViewDataSource 10 | let delegate: UITableViewDelegate? 11 | let dataSourcePrefetching: UITableViewDataSourcePrefetching? 12 | 13 | public init(id: AnyHashable, _ dataSource: UITableViewDataSource) { 14 | self.id = id 15 | self.dataSource = dataSource 16 | self.delegate = dataSource as? UITableViewDelegate 17 | self.dataSourcePrefetching = dataSource as? UITableViewDataSourcePrefetching 18 | } 19 | } 20 | 21 | extension CellController: Equatable { 22 | public static func == (lhs: CellController, rhs: CellController) -> Bool { 23 | lhs.id == rhs.id 24 | } 25 | } 26 | 27 | extension CellController: Hashable { 28 | public func hash(into hasher: inout Hasher) { 29 | hasher.combine(id) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Shared UI/Views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public final class ErrorView: UIButton { 8 | public var message: String? { 9 | get { return isVisible ? configuration?.title : nil } 10 | set { setMessageAnimated(newValue) } 11 | } 12 | 13 | public var onHide: (() -> Void)? 14 | 15 | public override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | configure() 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | } 23 | 24 | public override var intrinsicContentSize: CGSize { 25 | guard 26 | let size = titleLabel?.intrinsicContentSize, 27 | let insets = configuration?.contentInsets 28 | else { 29 | return super.intrinsicContentSize 30 | } 31 | 32 | return CGSize(width: size.width + insets.leading + insets.trailing, height: size.height + insets.top + insets.bottom) 33 | } 34 | 35 | public override func layoutSubviews() { 36 | super.layoutSubviews() 37 | 38 | if let insets = configuration?.contentInsets { 39 | titleLabel?.preferredMaxLayoutWidth = bounds.size.width - insets.leading - insets.trailing 40 | } 41 | } 42 | 43 | private var titleAttributes: AttributeContainer { 44 | let paragraphStyle = NSMutableParagraphStyle() 45 | paragraphStyle.alignment = NSTextAlignment.center 46 | 47 | return AttributeContainer([ 48 | .paragraphStyle: paragraphStyle, 49 | .font: UIFont.preferredFont(forTextStyle: .body) 50 | ]) 51 | } 52 | 53 | private func configure() { 54 | var configuration = Configuration.plain() 55 | configuration.titlePadding = 0 56 | configuration.baseForegroundColor = .white 57 | configuration.background.backgroundColor = .errorBackgroundColor 58 | configuration.background.cornerRadius = 0 59 | self.configuration = configuration 60 | 61 | addTarget(self, action: #selector(hideMessageAnimated), for: .touchUpInside) 62 | 63 | hideMessage() 64 | } 65 | 66 | private var isVisible: Bool { 67 | return alpha > 0 68 | } 69 | 70 | private func setMessageAnimated(_ message: String?) { 71 | if let message = message { 72 | showAnimated(message) 73 | } else { 74 | hideMessageAnimated() 75 | } 76 | } 77 | 78 | private func showAnimated(_ message: String) { 79 | configuration?.attributedTitle = AttributedString(message, attributes: titleAttributes) 80 | 81 | configuration?.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) 82 | 83 | UIView.animate(withDuration: 0.25) { 84 | self.alpha = 1 85 | } 86 | } 87 | 88 | @objc private func hideMessageAnimated() { 89 | UIView.animate( 90 | withDuration: 0.25, 91 | animations: { self.alpha = 0 }, 92 | completion: { completed in 93 | if completed { self.hideMessage() } 94 | }) 95 | } 96 | 97 | private func hideMessage() { 98 | alpha = 0 99 | configuration?.attributedTitle = nil 100 | configuration?.contentInsets = .zero 101 | onHide?() 102 | } 103 | } 104 | 105 | extension UIColor { 106 | static var errorBackgroundColor: UIColor { 107 | UIColor(red: 0.99951404330000004, green: 0.41759261489999999, blue: 0.4154433012, alpha: 1) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIRefreshControl+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+Dequeueing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeediOS/Shared UI/Views/Helpers/UITableView+HeaderSizing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UITableView { 8 | func sizeTableHeaderToFit() { 9 | guard let header = tableHeaderView else { return } 10 | 11 | let size = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) 12 | 13 | let needsFrameUpdate = header.frame.height != size.height 14 | if needsFrameUpdate { 15 | header.frame.size.height = size.height 16 | tableHeaderView = header 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOS/Shared UI/Views/Helpers/UIView+Container.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIView { 8 | 9 | public func makeContainer() -> UIView { 10 | let container = UIView() 11 | container.backgroundColor = .clear 12 | container.addSubview(self) 13 | 14 | translatesAutoresizingMaskIntoConstraints = false 15 | NSLayoutConstraint.activate([ 16 | leadingAnchor.constraint(equalTo: container.leadingAnchor), 17 | container.trailingAnchor.constraint(equalTo: trailingAnchor), 18 | topAnchor.constraint(equalTo: container.topAnchor), 19 | container.bottomAnchor.constraint(equalTo: bottomAnchor), 20 | ]) 21 | 22 | return container 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light_extraExtraExtraLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_CONTENT_light_extraExtraExtraLarge.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_FAILED_IMAGE_LOADING_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Helpers/UIImage+TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 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/EssentialFeediOSTests/Helpers/UIViewController+Snapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | extension UIViewController { 8 | func snapshot(for configuration: SnapshotConfiguration) -> UIImage { 9 | return SnapshotWindow(configuration: configuration, root: self).snapshot() 10 | } 11 | } 12 | 13 | struct SnapshotConfiguration { 14 | let size: CGSize 15 | let safeAreaInsets: UIEdgeInsets 16 | let layoutMargins: UIEdgeInsets 17 | let traitCollection: UITraitCollection 18 | 19 | static func iPhone(style: UIUserInterfaceStyle, contentSize: UIContentSizeCategory = .medium) -> SnapshotConfiguration { 20 | return SnapshotConfiguration( 21 | size: CGSize(width: 390, height: 844), 22 | safeAreaInsets: UIEdgeInsets(top: 47, left: 0, bottom: 34, right: 0), 23 | layoutMargins: UIEdgeInsets(top: 55, left: 8, bottom: 42, right: 8), 24 | traitCollection: UITraitCollection(mutations: { traits in 25 | traits.forceTouchCapability = .unavailable 26 | traits.layoutDirection = .leftToRight 27 | traits.preferredContentSizeCategory = contentSize 28 | traits.userInterfaceIdiom = .phone 29 | traits.horizontalSizeClass = .compact 30 | traits.verticalSizeClass = .regular 31 | traits.displayScale = 3 32 | traits.accessibilityContrast = .normal 33 | traits.displayGamut = .P3 34 | traits.userInterfaceStyle = style 35 | })) 36 | } 37 | } 38 | 39 | private final class SnapshotWindow: UIWindow { 40 | private var configuration: SnapshotConfiguration = .iPhone(style: .light) 41 | 42 | convenience init(configuration: SnapshotConfiguration, root: UIViewController) { 43 | self.init(frame: CGRect(origin: .zero, size: configuration.size)) 44 | self.configuration = configuration 45 | self.layoutMargins = configuration.layoutMargins 46 | self.rootViewController = root 47 | self.isHidden = false 48 | root.view.layoutMargins = configuration.layoutMargins 49 | } 50 | 51 | override var safeAreaInsets: UIEdgeInsets { 52 | configuration.safeAreaInsets 53 | } 54 | 55 | override var traitCollection: UITraitCollection { 56 | configuration.traitCollection 57 | } 58 | 59 | func snapshot() -> UIImage { 60 | let renderer = UIGraphicsImageRenderer(bounds: bounds, format: .init(for: traitCollection)) 61 | return renderer.image { action in 62 | layer.render(in: action.cgContext) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Helpers/XCTestCase+Snapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | 7 | extension XCTestCase { 8 | 9 | func assert(snapshot: UIImage, named name: String, file: StaticString = #filePath, line: UInt = #line) { 10 | let snapshotURL = makeSnapshotURL(named: name, file: file) 11 | let snapshotData = makeSnapshotData(for: snapshot, file: file, line: line) 12 | 13 | guard let storedSnapshotData = try? Data(contentsOf: snapshotURL) else { 14 | XCTFail("Failed to load stored snapshot at URL: \(snapshotURL). Use the `record` method to store a snapshot before asserting.", file: file, line: line) 15 | return 16 | } 17 | 18 | if snapshotData != storedSnapshotData { 19 | let temporarySnapshotURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) 20 | .appendingPathComponent(snapshotURL.lastPathComponent) 21 | 22 | try? snapshotData?.write(to: temporarySnapshotURL) 23 | 24 | XCTFail("New snapshot does not match stored snapshot. New snapshot URL: \(temporarySnapshotURL), Stored snapshot URL: \(snapshotURL)", file: file, line: line) 25 | } 26 | } 27 | 28 | func record(snapshot: UIImage, named name: String, file: StaticString = #filePath, line: UInt = #line) { 29 | let snapshotURL = makeSnapshotURL(named: name, file: file) 30 | let snapshotData = makeSnapshotData(for: snapshot, file: file, line: line) 31 | 32 | do { 33 | try FileManager.default.createDirectory( 34 | at: snapshotURL.deletingLastPathComponent(), 35 | withIntermediateDirectories: true 36 | ) 37 | 38 | try snapshotData?.write(to: snapshotURL) 39 | XCTFail("Record succeeded - use `assert` to compare the snapshot from now on.", file: file, line: line) 40 | } catch { 41 | XCTFail("Failed to record snapshot with error: \(error)", file: file, line: line) 42 | } 43 | } 44 | 45 | private func makeSnapshotURL(named name: String, file: StaticString) -> URL { 46 | return URL(fileURLWithPath: String(describing: file)) 47 | .deletingLastPathComponent() 48 | .appendingPathComponent("snapshots") 49 | .appendingPathComponent("\(name).png") 50 | } 51 | 52 | private func makeSnapshotData(for snapshot: UIImage, file: StaticString, line: UInt) -> Data? { 53 | guard let data = snapshot.pngData() else { 54 | XCTFail("Failed to generate PNG data representation from snapshot", file: file, line: line) 55 | return nil 56 | } 57 | 58 | return data 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeediOS 7 | @testable import EssentialFeed 8 | 9 | class ImageCommentsSnapshotTests: XCTestCase { 10 | 11 | func test_listWithComments() { 12 | let sut = makeSUT() 13 | 14 | sut.display(comments()) 15 | 16 | assert(snapshot: sut.snapshot(for: .iPhone(style: .light)), named: "IMAGE_COMMENTS_light") 17 | assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "IMAGE_COMMENTS_dark") 18 | assert(snapshot: sut.snapshot(for: .iPhone(style: .light, contentSize: .extraExtraExtraLarge)), named: "IMAGE_COMMENTS_light_extraExtraExtraLarge") 19 | } 20 | 21 | // MARK: - Helpers 22 | 23 | private func makeSUT() -> ListViewController { 24 | let bundle = Bundle(for: ListViewController.self) 25 | let storyboard = UIStoryboard(name: "ImageComments", bundle: bundle) 26 | let controller = storyboard.instantiateInitialViewController() as! ListViewController 27 | controller.loadViewIfNeeded() 28 | controller.tableView.showsVerticalScrollIndicator = false 29 | controller.tableView.showsHorizontalScrollIndicator = false 30 | return controller 31 | } 32 | 33 | private func comments() -> [CellController] { 34 | commentControllers().map { CellController(id: UUID(), $0) } 35 | } 36 | 37 | private func commentControllers() -> [ImageCommentCellController] { 38 | return [ 39 | ImageCommentCellController( 40 | model: ImageCommentViewModel( 41 | message: "The East Side Gallery is an open-air gallery in Berlin. It consists of a series of murals painted directly on a 1,316 m long remnant of the Berlin Wall, located near the centre of Berlin, on Mühlenstraße in Friedrichshain-Kreuzberg. The gallery has official status as a Denkmal, or heritage-protected landmark.", 42 | date: "1000 years ago", 43 | username: "a long long long long username" 44 | ) 45 | ), 46 | ImageCommentCellController( 47 | model: ImageCommentViewModel( 48 | message: "East Side Gallery\nMemorial in Berlin, Germany", 49 | date: "10 days ago", 50 | username: "a username" 51 | ) 52 | ), 53 | ImageCommentCellController( 54 | model: ImageCommentViewModel( 55 | message: "nice", 56 | date: "1 hour ago", 57 | username: "a." 58 | ) 59 | ), 60 | ] 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light_extraExtraExtraLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Image Comments UI/snapshots/IMAGE_COMMENTS_light_extraExtraExtraLarge.png -------------------------------------------------------------------------------- /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/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © Essential Developer. All rights reserved. 3 | // 4 | 5 | import XCTest 6 | import EssentialFeediOS 7 | @testable import EssentialFeed 8 | 9 | class ListSnapshotTests: XCTestCase { 10 | 11 | func test_emptyList() { 12 | let sut = makeSUT() 13 | 14 | sut.display(emptyList()) 15 | 16 | assert(snapshot: sut.snapshot(for: .iPhone(style: .light)), named: "EMPTY_LIST_light") 17 | assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "EMPTY_LIST_dark") 18 | } 19 | 20 | func test_listWithErrorMessage() { 21 | let sut = makeSUT() 22 | 23 | sut.display(.error(message: "This is a\nmulti-line\nerror message")) 24 | 25 | assert(snapshot: sut.snapshot(for: .iPhone(style: .light)), named: "LIST_WITH_ERROR_MESSAGE_light") 26 | assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "LIST_WITH_ERROR_MESSAGE_dark") 27 | assert(snapshot: sut.snapshot(for: .iPhone(style: .light, contentSize: .extraExtraExtraLarge)), named: "LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge") 28 | } 29 | 30 | // MARK: - Helpers 31 | 32 | private func makeSUT() -> ListViewController { 33 | let controller = ListViewController() 34 | controller.loadViewIfNeeded() 35 | controller.tableView.separatorStyle = .none 36 | controller.tableView.showsVerticalScrollIndicator = false 37 | controller.tableView.showsHorizontalScrollIndicator = false 38 | return controller 39 | } 40 | 41 | private func emptyList() -> [CellController] { 42 | return [] 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/EMPTY_LIST_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_dark.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light.png -------------------------------------------------------------------------------- /EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/EssentialFeed/EssentialFeediOSTests/Shared UI/snapshots/LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Prototype/Prototype.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Prototype/Prototype.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /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-0.imageset/image-0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg -------------------------------------------------------------------------------- /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-1.imageset/image-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg -------------------------------------------------------------------------------- /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-2.imageset/image-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg -------------------------------------------------------------------------------- /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-3.imageset/image-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg -------------------------------------------------------------------------------- /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-4.imageset/image-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png -------------------------------------------------------------------------------- /Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/architecture.png -------------------------------------------------------------------------------- /feed_flowchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/essentialdevelopercom/essential-feed-case-study/871b415a0577a4bfafa6a6a6183db062ff33cfe8/feed_flowchart.png --------------------------------------------------------------------------------