Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
335238d
Merge CellController initializers
PortoCode Mar 22, 2025
3a5de1e
Improve coverage with distinct image data per index
PortoCode Mar 22, 2025
76a3635
Add "Load more" cell controller with loading indicator
PortoCode Mar 22, 2025
be09459
Add "Load more" message label
PortoCode Mar 22, 2025
2daf9c3
Make feed loader closure return Paginated<FeedImage>
PortoCode Mar 24, 2025
f1ac0c9
Add default presentation mapper when the Resource type matches the Re…
PortoCode Mar 24, 2025
86d9b0f
Remove unused code
PortoCode Mar 24, 2025
aca4a2c
Load more items on will display LoadMoreCell
PortoCode Mar 24, 2025
455f226
Prevent load more action while loading more
PortoCode Mar 24, 2025
855e270
Does not load more after loading last page
PortoCode Mar 24, 2025
7a3fbb6
Show loading more indicator while loading more
PortoCode Mar 24, 2025
afb7309
Render items from "Load more" action
PortoCode Mar 24, 2025
4a7c726
Render load more error message
PortoCode Mar 24, 2025
c61481c
Load more on error tap
PortoCode Mar 24, 2025
4464883
Set FeedEndpoint limit to 10
PortoCode Mar 24, 2025
b690aff
Add feed endpoint with "after_id" param
PortoCode Mar 24, 2025
c3c104f
Load more items until last page
PortoCode Mar 24, 2025
b65cc71
Cache page results
PortoCode Mar 25, 2025
b4e948d
Extract logic into helper methods
PortoCode Mar 25, 2025
3a663de
Remove cell selection style
PortoCode Mar 25, 2025
2540ad3
Automatically load more items on scroll after an error
PortoCode Mar 25, 2025
d88b0b5
Fetch current items from cache when needed instead of keeping them in…
PortoCode Mar 25, 2025
0c5cdfc
Group "Load more" tests
PortoCode Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ import Foundation
import Combine
import EssentialFeed

public extension Paginated {
init(items: [Item], loadMorePublisher: (() -> AnyPublisher<Self, Error>)?) {
self.init(items: items, loadMore: loadMorePublisher.map { publisher in
return { completion in
publisher().subscribe(Subscribers.Sink(receiveCompletion: { result in
if case let .failure(error) = result {
completion(.failure(error))
}
}, receiveValue: { result in
completion(.success(result))
}))
}
})
}

var loadMorePublisher: (() -> AnyPublisher<Self, Error>)? {
guard let loadMore = loadMore else { return nil }

return {
Deferred {
Future(loadMore)
}
.eraseToAnyPublisher()
}
}
}

public extension HTTPClient {
typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error>

Expand Down Expand Up @@ -70,8 +97,12 @@ extension Publisher {
}
}

extension Publisher where Output == [FeedImage] {
func caching(to cache: FeedCache) -> AnyPublisher<Output, Failure> {
extension Publisher {
func caching(to cache: FeedCache) -> AnyPublisher<Output, Failure> where Output == [FeedImage] {
handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher()
}

func caching(to cache: FeedCache) -> AnyPublisher<Output, Failure> where Output == Paginated<FeedImage> {
handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher()
}
}
Expand All @@ -80,6 +111,10 @@ private extension FeedCache {
func saveIgnoringResult(_ feed: [FeedImage]) {
save(feed) { _ in }
}

func saveIgnoringResult(_ page: Paginated<FeedImage>) {
saveIgnoringResult(page.items)
}
}

extension Publisher {
Expand Down
7 changes: 3 additions & 4 deletions EssentialApp/EssentialApp/FeedUIComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import EssentialFeediOS
public final class FeedUIComposer {
private init() {}

private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter>
private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>

public static func feedComposedWith(
feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>,
feedLoader: @escaping () -> AnyPublisher<Paginated<FeedImage>, Error>,
imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher,
selection: @escaping (FeedImage) -> Void = { _ in }
) -> ListViewController {
Expand All @@ -29,8 +29,7 @@ public final class FeedUIComposer {
imageLoader: imageLoader,
selection: selection),
loadingView: WeakRefVirtualProxy(feedController),
errorView: WeakRefVirtualProxy(feedController),
mapper: FeedPresenter.map)
errorView: WeakRefVirtualProxy(feedController))

return feedController
}
Expand Down
24 changes: 21 additions & 3 deletions EssentialApp/EssentialApp/FeedViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ final class FeedViewAdapter: ResourceView {
private let selection: (FeedImage) -> Void

private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter<Data, WeakRefVirtualProxy<FeedImageCellController>>
private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>

init(controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) {
self.controller = controller
self.imageLoader = imageLoader
self.selection = selection
}

func display(_ viewModel: FeedViewModel) {
controller?.display(viewModel.feed.map { model in
func display(_ viewModel: Paginated<FeedImage>) {
let feed: [CellController] = viewModel.items.map { model in
let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in
imageLoader(model.url)
})
Expand All @@ -40,7 +41,24 @@ final class FeedViewAdapter: ResourceView {
mapper: UIImage.tryMake)

return CellController(id: model, view)
})
}

guard let loadMorePublisher = viewModel.loadMorePublisher else {
controller?.display(feed)
return
}

let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMorePublisher)
let loadMore = LoadMoreCellController(callback: loadMoreAdapter.loadResource)

loadMoreAdapter.presenter = LoadResourcePresenter(
resourceView: self,
loadingView: WeakRefVirtualProxy(loadMore),
errorView: WeakRefVirtualProxy(loadMore))

let loadMoreSection = [CellController(id: UUID(), loadMore)]

controller?.display(feed, loadMoreSection)
}
}

Expand Down
34 changes: 30 additions & 4 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,40 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> {
let url = FeedEndpoint.get.url(baseURL: baseURL)
private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<Paginated<FeedImage>, Error> {
makeRemoteFeedLoader()
.caching(to: localFeedLoader)
.fallback(to: localFeedLoader.loadPublisher)
.map(makeFirstPage)
.eraseToAnyPublisher()
}

private func makeRemoteLoadMoreLoader(last: FeedImage?) -> AnyPublisher<Paginated<FeedImage>, Error> {
localFeedLoader.loadPublisher()
.zip(makeRemoteFeedLoader(after: last))
.map { (cachedItems, newItems) in
(cachedItems + newItems, newItems.last)
}.map(makePage)
.caching(to: localFeedLoader)
}

private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> {
let url = FeedEndpoint.get(after: after).url(baseURL: baseURL)

return httpClient
.getPublisher(url: url)
.tryMap(FeedItemsMapper.map)
.caching(to: localFeedLoader)
.fallback(to: localFeedLoader.loadPublisher)
.eraseToAnyPublisher()
}

private func makeFirstPage(items: [FeedImage]) -> Paginated<FeedImage> {
makePage(items: items, last: items.last)
}

private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated<FeedImage> {
Paginated(items: items, loadMorePublisher: last.map { last in
{ self.makeRemoteLoadMoreLoader(last: last) }
})
}

private func makeLocalImageLoaderWithRemoteFallback(url: URL) -> FeedImageDataLoader.Publisher {
Expand Down
66 changes: 52 additions & 14 deletions EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,42 @@ class FeedAcceptanceTests: XCTestCase {
let feed = launch(httpClient: .online(response), store: .empty)

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2)
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData())
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData())
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1())
XCTAssertTrue(feed.canLoadMoreFeed)

feed.simulateLoadMoreFeedAction()

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3)
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1())
XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2())
XCTAssertTrue(feed.canLoadMoreFeed)

feed.simulateLoadMoreFeedAction()

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3)
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1())
XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2())
XCTAssertFalse(feed.canLoadMoreFeed)
}

func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() {
let sharedStore = InMemoryFeedStore.empty

let onlineFeed = launch(httpClient: .online(response), store: sharedStore)
onlineFeed.simulateFeedImageViewVisible(at: 0)
onlineFeed.simulateFeedImageViewVisible(at: 1)
onlineFeed.simulateLoadMoreFeedAction()
onlineFeed.simulateFeedImageViewVisible(at: 2)

let offlineFeed = launch(httpClient: .offline, store: sharedStore)

XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 2)
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData())
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData())
XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 3)
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData0())
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData1())
XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2())
}

func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() {
Expand Down Expand Up @@ -100,11 +121,18 @@ class FeedAcceptanceTests: XCTestCase {

private func makeData(for url: URL) -> Data {
switch url.path {
case "/image-1", "/image-2":
return makeImageData()
case "/image-0": return makeImageData0()
case "/image-1": return makeImageData1()
case "/image-2": return makeImageData2()

case "/essential-feed/v1/feed" where url.query?.contains("after_id") == false:
return makeFirstFeedPageData()

case "/essential-feed/v1/feed":
return makeFeedData()
case "/essential-feed/v1/feed" where url.query?.contains("after_id=A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A") == true:
return makeSecondFeedPageData()

case "/essential-feed/v1/feed" where url.query?.contains("after_id=166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F") == true:
return makeLastEmptyFeedPageData()

case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments":
return makeCommentsData()
Expand All @@ -114,17 +142,27 @@ class FeedAcceptanceTests: XCTestCase {
}
}

private func makeImageData() -> Data {
return UIImage.make(withColor: .red).pngData()!
private func makeImageData0() -> Data { UIImage.make(withColor: .red).pngData()! }
private func makeImageData1() -> Data { UIImage.make(withColor: .green).pngData()! }
private func makeImageData2() -> Data { UIImage.make(withColor: .blue).pngData()! }

private func makeFirstFeedPageData() -> Data {
return try! JSONSerialization.data(withJSONObject: ["items": [
["id": "2AB2AE66-A4B7-4A16-B374-51BBAC8DB086", "image": "http://feed.com/image-0"],
["id": "A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A", "image": "http://feed.com/image-1"]
]])
}

private func makeFeedData() -> Data {
private func makeSecondFeedPageData() -> Data {
return try! JSONSerialization.data(withJSONObject: ["items": [
["id": "2AB2AE66-A4B7-4A16-B374-51BBAC8DB086", "image": "http://feed.com/image-1"],
["id": "A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A", "image": "http://feed.com/image-2"]
["id": "166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F", "image": "http://feed.com/image-2"],
]])
}

private func makeLastEmptyFeedPageData() -> Data {
return try! JSONSerialization.data(withJSONObject: ["items": []])
}

private func makeCommentsData() -> Data {
return try! JSONSerialization.data(withJSONObject: ["items": [
[
Expand Down
Loading