Skip to content
Merged
26 changes: 21 additions & 5 deletions EssentialApp/EssentialApp/FeedViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ final class FeedViewAdapter: ResourceView {
private weak var controller: ListViewController?
private let imageLoader: (URL) -> FeedImageDataLoader.Publisher
private let selection: (FeedImage) -> Void
private let currentFeed: [FeedImage: CellController]

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) {
init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) {
self.currentFeed = currentFeed
self.controller = controller
self.imageLoader = imageLoader
self.selection = selection
}

func display(_ viewModel: Paginated<FeedImage>) {
guard let controller = controller else { return }

var currentFeed = self.currentFeed
let feed: [CellController] = viewModel.items.map { model in
if let controller = currentFeed[model] {
return controller
}

let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in
imageLoader(model.url)
})
Expand All @@ -40,25 +49,32 @@ final class FeedViewAdapter: ResourceView {
errorView: WeakRefVirtualProxy(view),
mapper: UIImage.tryMake)

return CellController(id: model, view)
let controller = CellController(id: model, view)
currentFeed[model] = controller
return controller
}

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

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

loadMoreAdapter.presenter = LoadResourcePresenter(
resourceView: self,
resourceView: FeedViewAdapter(
currentFeed: currentFeed,
controller: controller,
imageLoader: imageLoader,
selection: selection
),
loadingView: WeakRefVirtualProxy(loadMore),
errorView: WeakRefVirtualProxy(loadMore))

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

controller?.display(feed, loadMoreSection)
controller.display(feed, loadMoreSection)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,24 @@ import EssentialFeediOS
final class LoadResourcePresentationAdapter<Resource, View: ResourceView> {
private let loader: () -> AnyPublisher<Resource, Error>
private var cancellable: Cancellable?
private var isLoading = false
var presenter: LoadResourcePresenter<Resource, View>?

init(loader: @escaping () -> AnyPublisher<Resource, Error>) {
self.loader = loader
}

func loadResource() {
guard !isLoading else { return }

presenter?.didStartLoading()
isLoading = true

cancellable = loader()
.dispatchOnMainQueue()
.handleEvents(receiveCancel: { [weak self] in
self?.isLoading = false
})
.sink(
receiveCompletion: { [weak self] completion in
switch completion {
Expand All @@ -29,6 +36,8 @@ final class LoadResourcePresentationAdapter<Resource, View: ResourceView> {
case let .failure(error):
self?.presenter?.didFinishLoading(with: error)
}

self?.isLoading = false
}, receiveValue: { [weak self] resource in
self?.presenter?.didFinishLoading(with: resource)
})
Expand Down
29 changes: 29 additions & 0 deletions EssentialApp/EssentialApp/NullStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Created by Rodrigo Porto.
// Copyright © 2025 PortoCode. All Rights Reserved.
//

import Foundation
import EssentialFeed

class NullStore: FeedStore & FeedImageDataStore {
func deleteCachedFeed(completion: @escaping DeletionCompletion) {
completion(.success(()))
}

func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) {
completion(.success(()))
}

func retrieve(completion: @escaping RetrievalCompletion) {
completion(.success(.none))
}

func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) {
completion(.success(()))
}

func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) {
completion(.success(.none))
}
}
17 changes: 13 additions & 4 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Copyright © 2025 PortoCode. All Rights Reserved.
//

import os
import UIKit
import CoreData
import Combine
Expand All @@ -15,11 +16,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
URLSessionHTTPClient(session: URLSession(configuration: .ephemeral))
}()

private lazy var logger = Logger(subsystem: "portocode.EssentialApp", category: "main")

private lazy var store: FeedStore & FeedImageDataStore = {
try! CoreDataFeedStore(
storeURL: NSPersistentContainer
.defaultDirectoryURL()
.appendingPathComponent("feed-store.sqlite"))
do {
return try CoreDataFeedStore(
storeURL: NSPersistentContainer
.defaultDirectoryURL()
.appendingPathComponent("feed-store.sqlite"))
} catch {
assertionFailure("Failed to instantiate CoreData store with error: \(error.localizedDescription)")
logger.fault("Failed to instantiate CoreData store with error: \(error.localizedDescription)")
return NullStore()
}
}()

private lazy var localFeedLoader: LocalFeedLoader = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ class CommentsUIIntegrationTests: XCTestCase {
sut.simulateAppearance()
XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected a loading request once view appears")

sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no request until previous completes")

loader.completeCommentsLoading(at: 0)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 2, "Expected another loading request once user initiates a reload")

loader.completeCommentsLoading(at: 1)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 3, "Expected yet another loading request once user initiates another reload")
}
Expand Down Expand Up @@ -205,6 +210,7 @@ class CommentsUIIntegrationTests: XCTestCase {

func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) {
requests[index].send(comments)
requests[index].send(completion: .finished)
}

func completeCommentsLoadingWithError(at index: Int = 0) {
Expand Down
56 changes: 54 additions & 2 deletions EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,19 @@ class FeedUIIntegrationTests: XCTestCase {

func test_loadFeedActions_requestFeedFromLoader() {
let (sut, loader) = makeSUT()
XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears")
XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view is loaded")

sut.simulateAppearance()
XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears")
XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view is loaded")

sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no request until previous completes")

loader.completeFeedLoading(at: 0)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload")

loader.completeFeedLoading(at: 1)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload")
}
Expand Down Expand Up @@ -504,6 +509,27 @@ class FeedUIIntegrationTests: XCTestCase {
XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator when image loads successfully after view becomes visible again")
}

func test_feedImageView_configuresViewCorrectlyWhenTransitioningFromNearVisibleToVisibleWhileStillPreloadingImage() {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeFeedLoading(with: [makeImage()])

sut.simulateFeedImageViewNearVisible(at: 0)
let view0 = sut.simulateFeedImageViewVisible(at: 0)

XCTAssertEqual(view0?.renderedImage, nil, "Expected no rendered image when view becomes visible while still preloading image")
XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action when view becomes visible while still preloading image")
XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator when view becomes visible while still preloading image")

let imageData = UIImage.make(withColor: .red).pngData()!
loader.completeImageLoading(with: imageData, at: 0)

XCTAssertEqual(view0?.renderedImage, imageData, "Expected rendered image after image preloads successfully")
XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action after image preloads successfully")
XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator after image preloads successfully")
}

func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() {
let (sut, loader) = makeSUT()

Expand Down Expand Up @@ -531,6 +557,32 @@ class FeedUIIntegrationTests: XCTestCase {
wait(for: [exp], timeout: 1.0)
}

func test_feedImageView_doesNotLoadImageAgainUntilPreviousRequestCompletes() {
let image = makeImage(url: URL(string: "http://url-0.com")!)
let (sut, loader) = makeSUT()
sut.simulateAppearance()
loader.completeFeedLoading(with: [image])

sut.simulateFeedImageViewNearVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image.url], "Expected first request when near visible")

sut.simulateFeedImageViewVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image.url], "Expected no request until previous completes")

loader.completeImageLoading(at: 0)
sut.simulateFeedImageViewVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url], "Expected second request when visible after previous complete")

sut.simulateFeedImageViewNotVisible(at: 0)
sut.simulateFeedImageViewVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url, image.url], "Expected third request when visible after canceling previous complete")

sut.simulateLoadMoreFeedAction()
loader.completeLoadMore(with: [image, makeImage()])
sut.simulateFeedImageViewVisible(at: 0)
XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url, image.url], "Expected no request until previous completes")
}

// MARK: - Helpers

private func makeSUT(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ extension FeedUIIntegrationTests {
feedRequests[index].send(Paginated(items: feed, loadMorePublisher: { [weak self] in
self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher()
}))
feedRequests[index].send(completion: .finished)
}

func completeFeedLoadingWithError(at index: Int = 0) {
Expand Down
8 changes: 4 additions & 4 deletions EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
5BC4F6CB2CDAF0B20002D4CF /* CoreDataHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC4F6CA2CDAF0B20002D4CF /* CoreDataHelpers.swift */; };
5BC4F6CD2CDAF1B30002D4CF /* ManagedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC4F6CC2CDAF1B30002D4CF /* ManagedCache.swift */; };
5BC4F6CF2CDAF1C60002D4CF /* ManagedFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC4F6CE2CDAF1C60002D4CF /* ManagedFeedImage.swift */; };
5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift */; };
5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataStore.swift */; };
5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDE3C662D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift */; };
5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE36BA52CD5845700ACC57C /* FeedCachePolicy.swift */; };
5BF9F2F92CD9961400C8DB96 /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF9F2F82CD9961400C8DB96 /* FeedStoreSpecs.swift */; };
Expand Down Expand Up @@ -313,7 +313,7 @@
5BC4F6CC2CDAF1B30002D4CF /* ManagedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedCache.swift; sourceTree = "<group>"; };
5BC4F6CE2CDAF1C60002D4CF /* ManagedFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedFeedImage.swift; sourceTree = "<group>"; };
5BDE3C632D6C1672005D520D /* FeedStore2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FeedStore2.xcdatamodel; sourceTree = "<group>"; };
5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreDataFeedStore+FeedImageDataLoader.swift"; sourceTree = "<group>"; };
5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreDataFeedStore+FeedImageDataStore.swift"; sourceTree = "<group>"; };
5BDE3C662D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreDataFeedStore+FeedStore.swift"; sourceTree = "<group>"; };
5BE36BA52CD5845700ACC57C /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = "<group>"; };
5BF9F2F82CD9961400C8DB96 /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -890,7 +890,7 @@
children = (
5BF9F3092CDAD24D00C8DB96 /* CoreDataFeedStore.swift */,
5BDE3C662D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift */,
5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift */,
5BDE3C642D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataStore.swift */,
5BC4F6CA2CDAF0B20002D4CF /* CoreDataHelpers.swift */,
5BC4F6CC2CDAF1B30002D4CF /* ManagedCache.swift */,
5BC4F6CE2CDAF1C60002D4CF /* ManagedFeedImage.swift */,
Expand Down Expand Up @@ -1213,7 +1213,7 @@
5B88290A2D6A7B76006E0BD7 /* ResourceErrorViewModel.swift in Sources */,
5B8829142D6BAE59006E0BD7 /* FeedImageDataLoader.swift in Sources */,
5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */,
5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift in Sources */,
5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataStore.swift in Sources */,
5B88290F2D6A94C3006E0BD7 /* FeedImagePresenter.swift in Sources */,
5BBDA00E2D6FCCF000D68DF0 /* FeedCache.swift in Sources */,
5BC4F6CF2CDAF1C60002D4CF /* ManagedFeedImage.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension CoreDataFeedStore: FeedImageDataStore {
public func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) {
perform { context in
completion(Result {
try ManagedFeedImage.first(with: url, in: context)?.data
try ManagedFeedImage.data(with: url, in: context)
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ extension CoreDataFeedStore: FeedStore {
public func deleteCachedFeed(completion: @escaping DeletionCompletion) {
perform { context in
completion(Result {
try ManagedCache.find(in: context).map(context.delete).map(context.save)
try ManagedCache.deleteCache(in: context)
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ extension ManagedCache {
return try context.fetch(request).first
}

static func deleteCache(in context: NSManagedObjectContext) throws {
try find(in: context).map(context.delete).map(context.save)
}

static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache {
try find(in: context).map(context.delete)
try deleteCache(in: context)
return ManagedCache(context: context)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class ManagedFeedImage: NSManagedObject {
}

extension ManagedFeedImage {
static func data(with url: URL, in context: NSManagedObjectContext) throws -> Data? {
if let data = context.userInfo[url] as? Data { return data }

return try first(with: url, in: context)?.data
}

static func first(with url: URL, in context: NSManagedObjectContext) throws -> ManagedFeedImage? {
let request = NSFetchRequest<ManagedFeedImage>(entityName: entity().name!)
request.predicate = NSPredicate(format: "%K = %@", argumentArray: [#keyPath(ManagedFeedImage.url), url])
Expand All @@ -25,17 +31,26 @@ extension ManagedFeedImage {
}

static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet {
return NSOrderedSet(array: localFeed.map { local in
let images = NSOrderedSet(array: localFeed.map { local in
let managed = ManagedFeedImage(context: context)
managed.id = local.id
managed.imageDescription = local.description
managed.location = local.location
managed.url = local.url
managed.data = context.userInfo[local.url] as? Data
return managed
})
context.userInfo.removeAllObjects()
return images
}

var local: LocalFeedImage {
return LocalFeedImage(id: id, description: imageDescription, location: location, url: url)
}

override func prepareForDeletion() {
super.prepareForDeletion()

managedObjectContext?.userInfo[url] = data
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ extension FeedImageCellController: UITableViewDataSource, UITableViewDelegate, U
cell?.locationLabel.text = viewModel.location
cell?.descriptionLabel.text = viewModel.description
cell?.feedImageView.image = nil
cell?.feedImageContainer.isShimmering = true
cell?.feedImageRetryButton.isHidden = true
cell?.onRetry = { [weak self] in
self?.delegate.didRequestImage()
}
Expand Down