diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 93af5c0..20059d5 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -49,6 +49,10 @@ 5B73493A2D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */; }; 5B73493C2D84E463007F7D5D /* ResourceLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */; }; 5B73493E2D84FA14007F7D5D /* ResourceErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73493D2D84FA14007F7D5D /* ResourceErrorView.swift */; }; + 5B7349472D8532C9007F7D5D /* ImageCommentsPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349462D8532C9007F7D5D /* ImageCommentsPresenterTests.swift */; }; + 5B73494A2D85337A007F7D5D /* ImageCommentsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349492D85337A007F7D5D /* ImageCommentsPresenter.swift */; }; + 5B73494C2D853421007F7D5D /* ImageComments.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B73494B2D853421007F7D5D /* ImageComments.xcstrings */; }; + 5B73494E2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73494D2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift */; }; 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD192D649D0D007478DC /* ErrorView.swift */; }; 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */; }; 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */; }; @@ -199,6 +203,10 @@ 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocalizationTestHelpers.swift; sourceTree = ""; }; 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingView.swift; sourceTree = ""; }; 5B73493D2D84FA14007F7D5D /* ResourceErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceErrorView.swift; sourceTree = ""; }; + 5B7349462D8532C9007F7D5D /* ImageCommentsPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsPresenterTests.swift; sourceTree = ""; }; + 5B7349492D85337A007F7D5D /* ImageCommentsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsPresenter.swift; sourceTree = ""; }; + 5B73494B2D853421007F7D5D /* ImageComments.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = ImageComments.xcstrings; sourceTree = ""; }; + 5B73494D2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsLocalizationTests.swift; sourceTree = ""; }; 5B74FD192D649D0D007478DC /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Helpers.swift"; sourceTree = ""; }; 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenterTests.swift; sourceTree = ""; }; @@ -377,6 +385,7 @@ 5B73491B2D825610007F7D5D /* Shared API Infra */, 5B7349172D824BF6007F7D5D /* Image Comments Feature */, 5B7349212D825864007F7D5D /* Image Comments API */, + 5B7349482D853348007F7D5D /* Image Comments Presentation */, 5B107E172BF5C12800927709 /* Feed Feature */, 5B7BDE002BFA81830002E7F8 /* Feed API */, 5B034B312C9A801400FB65F8 /* Feed Cache */, @@ -393,6 +402,7 @@ 5B7349292D8439FF007F7D5D /* Shared Presentation */, 5B73491E2D825711007F7D5D /* Shared API Infra */, 5B7349222D8258A5007F7D5D /* Image Comments API */, + 5B7349452D853206007F7D5D /* Image Comments Presentation */, 5B0E220E2BFE404F009FC3EB /* Feed API */, 5B8BD18C2C37985A00CCA870 /* Feed Cache */, 5B74FD202D6A5F07007478DC /* Feed Presentation */, @@ -547,6 +557,24 @@ path = "Shared Presentation"; sourceTree = ""; }; + 5B7349452D853206007F7D5D /* Image Comments Presentation */ = { + isa = PBXGroup; + children = ( + 5B7349462D8532C9007F7D5D /* ImageCommentsPresenterTests.swift */, + 5B73494D2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift */, + ); + path = "Image Comments Presentation"; + sourceTree = ""; + }; + 5B7349482D853348007F7D5D /* Image Comments Presentation */ = { + isa = PBXGroup; + children = ( + 5B7349492D85337A007F7D5D /* ImageCommentsPresenter.swift */, + 5B73494B2D853421007F7D5D /* ImageComments.xcstrings */, + ); + path = "Image Comments Presentation"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -889,6 +917,7 @@ files = ( 5B7349332D844B03007F7D5D /* Shared.xcstrings in Resources */, 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */, + 5B73494C2D853421007F7D5D /* ImageComments.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -946,6 +975,7 @@ 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, + 5B73494A2D85337A007F7D5D /* ImageCommentsPresenter.swift in Sources */, 5B034B392C9BD2C000FB65F8 /* LocalFeedImage.swift in Sources */, 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */, 5B034B352C9A819900FB65F8 /* FeedStore.swift in Sources */, @@ -985,6 +1015,7 @@ 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */, 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */, 5BF9F3012CD9A10500C8DB96 /* XCTestCase+FailableInsertFeedStoreSpecs.swift in Sources */, + 5B7349472D8532C9007F7D5D /* ImageCommentsPresenterTests.swift in Sources */, 5B8829292D6BF226006E0BD7 /* CacheFeedImageDataUseCaseTests.swift in Sources */, 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */, 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */, @@ -995,6 +1026,7 @@ 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */, 5B7349352D844D6D007F7D5D /* SharedLocalizationTests.swift in Sources */, 5B8829262D6BF168006E0BD7 /* FeedImageDataStoreSpy.swift in Sources */, + 5B73494E2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift in Sources */, 5B88292B2D6BF4F0006E0BD7 /* CoreDataFeedImageDataStoreTests.swift in Sources */, 5BF9F3032CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, 5BF9F2FF2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageComments.xcstrings b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageComments.xcstrings new file mode 100644 index 0000000..5a39c1d --- /dev/null +++ b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageComments.xcstrings @@ -0,0 +1,30 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "IMAGE_COMMENTS_VIEW_TITLE" : { + "comment" : "Title for the image comments view", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comments" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentarios" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentários" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift new file mode 100644 index 0000000..9db6e69 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift @@ -0,0 +1,50 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public struct ImageCommentsViewModel { + public let comments: [ImageCommentViewModel] +} + +public struct ImageCommentViewModel: Equatable { + public let message: String + public let date: String + public let username: String + + public init(message: String, date: String, username: String) { + self.message = message + self.date = date + self.username = username + } +} + +public final class ImageCommentsPresenter { + public static var title: String { + NSLocalizedString( + "IMAGE_COMMENTS_VIEW_TITLE", + tableName: "ImageComments", + bundle: Bundle(for: Self.self), + comment: "Title for the image comments view") + } + + public static func map( + _ comments: [ImageComment], + currentDate: Date = Date(), + calendar: Calendar = .current, + locale: Locale = .current + ) -> ImageCommentsViewModel { + let formatter = RelativeDateTimeFormatter() + formatter.calendar = calendar + formatter.locale = locale + + return ImageCommentsViewModel(comments: comments.map { comment in + ImageCommentViewModel( + message: comment.message, + date: formatter.localizedString(for: comment.createdAt, relativeTo: currentDate), + username: comment.username) + }) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift index ab0732b..5c936c6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -25,14 +25,4 @@ extension Date { private var feedCacheMaxAgeInDays: Int { return 7 } - - private func adding(days: Int) -> Date { - return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! - } -} - -extension Date { - func adding(seconds: TimeInterval) -> Date { - return self + seconds - } } diff --git a/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift index e3dd654..267eb43 100644 --- a/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift @@ -27,3 +27,17 @@ extension HTTPURLResponse { self.init(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)! } } + +extension Date { + func adding(seconds: TimeInterval) -> Date { + return self + seconds + } + + func adding(minutes: Int, calendar: Calendar = Calendar(identifier: .gregorian)) -> Date { + return calendar.date(byAdding: .minute, value: minutes, to: self)! + } + + func adding(days: Int, calendar: Calendar = Calendar(identifier: .gregorian)) -> Date { + return calendar.date(byAdding: .day, value: days, to: self)! + } +} diff --git a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift new file mode 100644 index 0000000..fa07b9b --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift @@ -0,0 +1,18 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class ImageCommentsLocalizationTests: XCTestCase { + + func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { + let table = "ImageComments" + let bundle = Bundle(for: ImageCommentsPresenter.self) + + assertLocalizedKeyAndValuesExist(in: bundle, table) + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift new file mode 100644 index 0000000..d144f12 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift @@ -0,0 +1,66 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class ImageCommentsPresenterTests: XCTestCase { + + func test_title_isLocalized() { + XCTAssertEqual(ImageCommentsPresenter.title, localized("IMAGE_COMMENTS_VIEW_TITLE")) + } + + func test_map_createsViewModels() { + let now = Date() + let calendar = Calendar(identifier: .gregorian) + let locale = Locale(identifier: "en_US_POSIX") + + let comments = [ + ImageComment( + id: UUID(), + message: "a message", + createdAt: now.adding(minutes: -5, calendar: calendar), + username: "a username"), + ImageComment( + id: UUID(), + message: "another message", + createdAt: now.adding(days: -1, calendar: calendar), + username: "another username") + ] + + let viewModel = ImageCommentsPresenter.map( + comments, + currentDate: now, + calendar: calendar, + locale: locale + ) + + XCTAssertEqual(viewModel.comments, [ + ImageCommentViewModel( + message: "a message", + date: "5 minutes ago", + username: "a username" + ), + ImageCommentViewModel( + message: "another message", + date: "1 day ago", + username: "another username" + ) + ]) + } + + // MARK: - Helpers + + private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { + let table = "ImageComments" + let bundle = Bundle(for: ImageCommentsPresenter.self) + let value = bundle.localizedString(forKey: key, value: nil, table: table) + if value == key { + XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) + } + return value + } + +}