Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 0 additions & 26 deletions EssentialApp/EssentialApp/CombineHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,6 @@ 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()
}
}
}

@MainActor
public extension HTTPClient {
typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error>
Expand Down
5 changes: 2 additions & 3 deletions EssentialApp/EssentialApp/FeedUIComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
//

import UIKit
import Combine
import EssentialFeed
import EssentialFeediOS

@MainActor
public final class FeedUIComposer {
private init() {}

private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>
private typealias FeedPresentationAdapter = AsyncLoadResourcePresentationAdapter<Paginated<FeedImage>, FeedViewAdapter>

public static func feedComposedWith(
feedLoader: @MainActor @escaping () -> AnyPublisher<Paginated<FeedImage>, Error>,
feedLoader: @MainActor @escaping () async throws -> Paginated<FeedImage>,
imageLoader: @MainActor @escaping (URL) async throws -> Data,
selection: @MainActor @escaping (FeedImage) -> Void = { _ in }
) -> ListViewController {
Expand Down
6 changes: 3 additions & 3 deletions EssentialApp/EssentialApp/FeedViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class FeedViewAdapter: ResourceView {
private let currentFeed: [FeedImage: CellController]

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

init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) async throws -> Data, selection: @escaping (FeedImage) -> Void) {
self.currentFeed = currentFeed
Expand Down Expand Up @@ -54,12 +54,12 @@ final class FeedViewAdapter: ResourceView {
return controller
}

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

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

loadMoreAdapter.presenter = LoadResourcePresenter(
Expand Down
67 changes: 41 additions & 26 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

private lazy var navigationController = UINavigationController(
rootViewController: FeedUIComposer.feedComposedWith(
feedLoader: makeRemoteFeedLoaderWithLocalFallback,
feedLoader: loadRemoteFeedWithLocalFallback,
imageLoader: loadLocalImageWithRemoteFallback,
selection: showComments))

Expand Down Expand Up @@ -96,44 +96,59 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<Paginated<FeedImage>, Error> {
makeRemoteFeedLoader()
.receive(on: scheduler)
.caching(to: localFeedLoader)
.fallback(to: localFeedLoader.loadPublisher)
.map(makeFirstPage)
.eraseToAnyPublisher()
private func loadRemoteFeedWithLocalFallback() async throws -> Paginated<FeedImage> {
do {
let feed = try await loadAndCacheRemoteFeed()
return makeFirstPage(items: feed)
} catch {
let feed = try await loadLocalFeed()
return makeFirstPage(items: feed)
}
}

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)
.receive(on: scheduler)
.caching(to: localFeedLoader)
.subscribe(on: scheduler)
.eraseToAnyPublisher()
private func loadAndCacheRemoteFeed() async throws -> [FeedImage] {
let feed = try await loadRemoteFeed()
await store.schedule { [store] in
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
try? localFeedLoader.save(feed)
}
return feed
}

private func loadLocalFeed() async throws -> [FeedImage] {
try await store.schedule { [store] in
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
return try localFeedLoader.load()
}
}

private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> {
private func loadRemoteFeed(after: FeedImage? = nil) async throws -> [FeedImage] {
let url = FeedEndpoint.get(after: after).url(baseURL: baseURL)
let (data, response) = try await httpClient.get(from: url)
return try FeedItemsMapper.map(data, from: response)
}

private func loadMoreRemoteFeed(last: FeedImage?) async throws -> Paginated<FeedImage> {
async let cachedItems = try await loadLocalFeed()
async let newItems = try await loadRemoteFeed(after: last)

let items = try await cachedItems + newItems

await store.schedule { [store] in
let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init)
try? localFeedLoader.save(items)
}

return httpClient
.getPublisher(url: url)
.tryMap(FeedItemsMapper.map)
.eraseToAnyPublisher()
return try await makePage(items: items, last: newItems.last)
}

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) }
Paginated(items: items, loadMore: last.map { last in
{ @MainActor @Sendable in try await self.loadMoreRemoteFeed(last: last) }
})
}

Expand Down
34 changes: 30 additions & 4 deletions EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ import EssentialFeediOS
class FeedAcceptanceTests: XCTestCase {

func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws {
let feed = try launch(httpClient: .online(response), store: .empty)
let store = try CoreDataFeedStore.empty
let feed = try launch(httpClient: .online(response), store: store)

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

feed.simulateLoadMoreFeedAction()
try store.withWaitingChanges {
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()
try store.withWaitingChanges {
feed.simulateLoadMoreFeedAction()
}

XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3)
XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0())
Expand All @@ -41,7 +46,9 @@ class FeedAcceptanceTests: XCTestCase {
let onlineFeed = try launch(httpClient: .online(response), store: sharedStore)
onlineFeed.simulateFeedImageViewVisible(at: 0)
onlineFeed.simulateFeedImageViewVisible(at: 1)
onlineFeed.simulateLoadMoreFeedAction()
try sharedStore.withWaitingChanges {
onlineFeed.simulateLoadMoreFeedAction()
}
onlineFeed.simulateFeedImageViewVisible(at: 2)

let offlineFeed = try launch(httpClient: .offline, store: sharedStore)
Expand Down Expand Up @@ -186,6 +193,25 @@ class FeedAcceptanceTests: XCTestCase {

@MainActor
extension CoreDataFeedStore {
private struct Timeout: Error {}

func withWaitingChanges(_ action: () -> Void, timeout: TimeInterval = 1) throws {
let state = try retrieve()?.timestamp
action()

let maxDate = Date() + timeout

while Date() <= maxDate {
if try retrieve()?.timestamp != state {
return
}

RunLoop.current.run(until: Date())
}

throw Timeout()
}

static var empty: CoreDataFeedStore {
get throws {
try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main)
Expand Down
Loading