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