diff --git a/HomeCafeRecipes/HomeCafeRecipes.xcodeproj/project.pbxproj b/HomeCafeRecipes/HomeCafeRecipes.xcodeproj/project.pbxproj index fc06db7..8eb799d 100644 --- a/HomeCafeRecipes/HomeCafeRecipes.xcodeproj/project.pbxproj +++ b/HomeCafeRecipes/HomeCafeRecipes.xcodeproj/project.pbxproj @@ -7,6 +7,18 @@ objects = { /* Begin PBXBuildFile section */ + 1D0173AC2CB162CA00FF04BA /* commentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0173AB2CB162CA00FF04BA /* commentViewModel.swift */; }; + 1D0173AE2CB171DF00FF04BA /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0173AD2CB171DF00FF04BA /* Comment.swift */; }; + 1D0173B02CB1847C00FF04BA /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0173AF2CB1847C00FF04BA /* CommentView.swift */; }; + 1D0173B22CB1856300FF04BA /* CommentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0173B12CB1856300FF04BA /* CommentViewController.swift */; }; + 1D0173B42CB185C000FF04BA /* CommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0173B32CB185C000FF04BA /* CommentCell.swift */; }; + 1D0635E12CB28A8700DCC9EA /* CommentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0635E02CB28A8700DCC9EA /* CommentService.swift */; }; + 1D0635E42CB28B8700DCC9EA /* CommentInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0635E32CB28B8700DCC9EA /* CommentInteractor.swift */; }; + 1D0635E62CB2A63200DCC9EA /* FetchCommentUsecase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0635E52CB2A63200DCC9EA /* FetchCommentUsecase.swift */; }; + 1D0635E82CB2AA0600DCC9EA /* CommentListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0635E72CB2AA0600DCC9EA /* CommentListRepository.swift */; }; + 1D0635EA2CB2B0CD00DCC9EA /* CommentDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0635E92CB2B0CD00DCC9EA /* CommentDTO.swift */; }; + 1D0BE5822CB5652500F54A26 /* CommnetMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0BE5812CB5652500F54A26 /* CommnetMapper.swift */; }; + 1D0BE5832CB575B800F54A26 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1D2C16EE2BE532B800C04508 /* Assets.xcassets */; }; 1D1283A22C15E94300C5A870 /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1283A12C15E94300C5A870 /* Recipe.swift */; }; 1D1283A42C15EA8100C5A870 /* RecipeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1283A32C15EA8100C5A870 /* RecipeType.swift */; }; 1D1283AA2C15EBCF00C5A870 /* SearchFeedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1283A92C15EBCF00C5A870 /* SearchFeedUseCase.swift */; }; @@ -156,6 +168,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 1D0173AB2CB162CA00FF04BA /* commentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = commentViewModel.swift; sourceTree = ""; }; + 1D0173AD2CB171DF00FF04BA /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + 1D0173AF2CB1847C00FF04BA /* CommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentView.swift; sourceTree = ""; }; + 1D0173B12CB1856300FF04BA /* CommentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewController.swift; sourceTree = ""; }; + 1D0173B32CB185C000FF04BA /* CommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentCell.swift; sourceTree = ""; }; + 1D0635E02CB28A8700DCC9EA /* CommentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentService.swift; sourceTree = ""; }; + 1D0635E32CB28B8700DCC9EA /* CommentInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentInteractor.swift; sourceTree = ""; }; + 1D0635E52CB2A63200DCC9EA /* FetchCommentUsecase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCommentUsecase.swift; sourceTree = ""; }; + 1D0635E72CB2AA0600DCC9EA /* CommentListRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentListRepository.swift; sourceTree = ""; }; + 1D0635E92CB2B0CD00DCC9EA /* CommentDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDTO.swift; sourceTree = ""; }; + 1D0BE5812CB5652500F54A26 /* CommnetMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommnetMapper.swift; sourceTree = ""; }; 1D1283A12C15E94300C5A870 /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = ""; }; 1D1283A32C15EA8100C5A870 /* RecipeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeType.swift; sourceTree = ""; }; 1D1283A92C15EBCF00C5A870 /* SearchFeedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFeedUseCase.swift; sourceTree = ""; }; @@ -282,6 +305,25 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1D0173AA2CB15E2A00FF04BA /* comment */ = { + isa = PBXGroup; + children = ( + 1D0635E22CB28ADE00DCC9EA /* View */, + 1D0173AB2CB162CA00FF04BA /* commentViewModel.swift */, + 1D0173AF2CB1847C00FF04BA /* CommentView.swift */, + 1D0173B12CB1856300FF04BA /* CommentViewController.swift */, + 1D0173B32CB185C000FF04BA /* CommentCell.swift */, + ); + path = comment; + sourceTree = ""; + }; + 1D0635E22CB28ADE00DCC9EA /* View */ = { + isa = PBXGroup; + children = ( + ); + path = View; + sourceTree = ""; + }; 1D12839F2C15E7A700C5A870 /* Entities */ = { isa = PBXGroup; children = ( @@ -293,6 +335,7 @@ 1DD4F7092C80C947003E9D9D /* LoginError.swift */, 1D2398B32C8DC23500626F0C /* SignUpError.swift */, 1DDF485A2C93DFC9000A082E /* CheckEmailError.swift */, + 1D0173AD2CB171DF00FF04BA /* Comment.swift */, ); path = Entities; sourceTree = ""; @@ -307,6 +350,7 @@ 1D7641442C81BE90002AC68F /* LoginUseCase.swift */, 1D2398B12C8DC07800626F0C /* SignUpUseCase.swift */, 1DFC961F2C908739006C3309 /* CheckEmailUsecase.swift */, + 1D0635E52CB2A63200DCC9EA /* FetchCommentUsecase.swift */, ); path = UseCases; sourceTree = ""; @@ -338,6 +382,7 @@ 1D39726B2C458CE100495014 /* MultipartFormDataRequest.swift */, 1D7641482C831295002AC68F /* LoginService.swift */, 1DBD90B82C91BDAC00184F67 /* SignUpService.swift */, + 1D0635E02CB28A8700DCC9EA /* CommentService.swift */, ); path = Network; sourceTree = ""; @@ -451,6 +496,7 @@ 1D7368852C33D7BE000EF904 /* RecipeUploadResponseDTO.swift */, 1DBD90BA2C91DE1600184F67 /* EmptyResponse.swift */, 1DDF485C2C9405CF000A082E /* CheckEmailRespones.swift */, + 1D0635E92CB2B0CD00DCC9EA /* CommentDTO.swift */, ); path = DTO; sourceTree = ""; @@ -487,6 +533,7 @@ isa = PBXGroup; children = ( 1D3972672C44185B00495014 /* RecipeListMapper.swift */, + 1D0BE5812CB5652500F54A26 /* CommnetMapper.swift */, ); path = Mapper; sourceTree = ""; @@ -499,6 +546,7 @@ 1D73686F2C32BFBB000EF904 /* AddRecipeInteractor.swift */, 1D96FDA92C7F55E600EFC657 /* LoginInteractor.swift */, 1DF6E1422C8C561E005E8875 /* SignUpInteractor.swift */, + 1D0635E32CB28B8700DCC9EA /* CommentInteractor.swift */, ); path = Interactor; sourceTree = ""; @@ -513,6 +561,7 @@ 1D7641462C831192002AC68F /* LoginRepository.swift */, 1DFC961B2C90809D006C3309 /* SignUpRepository.swift */, 1DFC961D2C908723006C3309 /* CheckEmailRepository.swift */, + 1D0635E72CB2AA0600DCC9EA /* CommentListRepository.swift */, ); path = Repositories; sourceTree = ""; @@ -520,6 +569,7 @@ 1DE19EB22C1B422F0031804A /* Presentation */ = { isa = PBXGroup; children = ( + 1D0173AA2CB15E2A00FF04BA /* comment */, 1DF0D1A72C7DF99F00E2C94C /* Login */, 1D2C6F662C24697F004BB54E /* UploadRecipe */, 1D2C6F612C2446AF004BB54E /* Tabbar */, @@ -724,6 +774,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1D0BE5832CB575B800F54A26 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -751,6 +802,7 @@ 1DBB550A2C8421920009E033 /* SignUpViewController.swift in Sources */, 1D439E9E2C2C598A008530A5 /* RecipeDetailRepository.swift in Sources */, 1D2C6F6C2C27051D004BB54E /* CustomNavigationBar.swift in Sources */, + 1D0173B42CB185C000FF04BA /* CommentCell.swift in Sources */, 1DD4F70A2C80C947003E9D9D /* LoginError.swift in Sources */, 1D3972682C44185B00495014 /* RecipeListMapper.swift in Sources */, 1D2C16EA2BE532B700C04508 /* ViewController.swift in Sources */, @@ -768,6 +820,7 @@ 1DE19EB12C1B42200031804A /* NetworkService.swift in Sources */, 1DC7CC322C283C0200796889 /* RecipeUploadImgaeCell.swift in Sources */, 1D7368782C32E7FE000EF904 /* RecipePostService.swift in Sources */, + 1D0635EA2CB2B0CD00DCC9EA /* CommentDTO.swift in Sources */, 1D2398B42C8DC23500626F0C /* SignUpError.swift in Sources */, 1DBC63662C47D23000DA00C2 /* AddRecipeError.swift in Sources */, 1D95A0A62C37C79500F09077 /* RecipeDetailError.swift in Sources */, @@ -784,8 +837,12 @@ 1DF829B72C2A7CDC00C337FC /* UIImageViewImageLoading.swift in Sources */, 1D5AEE532C592A9900BBD5F0 /* RecipeListItemViewModel.swift in Sources */, 1D60CC3D2C3E4F1600D08FA3 /* APIConfig.swift in Sources */, + 1D0173B02CB1847C00FF04BA /* CommentView.swift in Sources */, + 1D0635E42CB28B8700DCC9EA /* CommentInteractor.swift in Sources */, 1D6F99492C9291B700430FD8 /* UIViewController+Alert.swift in Sources */, 1D1283A42C15EA8100C5A870 /* RecipeType.swift in Sources */, + 1D0173AC2CB162CA00FF04BA /* commentViewModel.swift in Sources */, + 1D0635E62CB2A63200DCC9EA /* FetchCommentUsecase.swift in Sources */, 1D7641472C831192002AC68F /* LoginRepository.swift in Sources */, 1DF829B42C2A7A7D00C337FC /* Fonts.swift in Sources */, 1DF0D1AD2C7DF9BB00E2C94C /* LoginView.swift in Sources */, @@ -796,19 +853,23 @@ 1DBB55062C8418490009E033 /* LoginRouter.swift in Sources */, 1DFC961E2C908723006C3309 /* CheckEmailRepository.swift in Sources */, 1D1283AA2C15EBCF00C5A870 /* SearchFeedUseCase.swift in Sources */, + 1D0BE5822CB5652500F54A26 /* CommnetMapper.swift in Sources */, 1DE19EA82C1B420A0031804A /* SearchFeedListRepository.swift in Sources */, 1DE19EC32C1B422F0031804A /* SearchBar.swift in Sources */, 1D439EA22C2C6997008530A5 /* RecipeDetailInteractor.swift in Sources */, 1D73686E2C305757000EF904 /* RecipeDetailDTO.swift in Sources */, + 1D0635E12CB28A8700DCC9EA /* CommentService.swift in Sources */, 1D4741D72C1B4FF4009381CE /* RecipeListInteractor.swift in Sources */, 1D2398B22C8DC07800626F0C /* SignUpUseCase.swift in Sources */, 1DC7CC342C294F9200796889 /* SelectImageCell.swift in Sources */, 1DE19E9D2C1B3DC10031804A /* SceneDelegate.swift in Sources */, + 1D0173B22CB1856300FF04BA /* CommentViewController.swift in Sources */, 1DDF485B2C93DFC9000A082E /* CheckEmailError.swift in Sources */, 1D4741D12C1B4F8D009381CE /* RecipeImageDTO.swift in Sources */, 1DF6E1432C8C561E005E8875 /* SignUpInteractor.swift in Sources */, 1DF0D1AB2C7DF9B500E2C94C /* LoginViewController.swift in Sources */, 1D7368742C32CF09000EF904 /* AddRecipeRepository.swift in Sources */, + 1D0635E82CB2AA0600DCC9EA /* CommentListRepository.swift in Sources */, 1DE19EA72C1B420A0031804A /* FeedListRepository.swift in Sources */, 1DF0D19B2C7B92D600E2C94C /* UserDTO.swift in Sources */, 1DE19EC62C1B422F0031804A /* RecipeListCell.swift in Sources */, @@ -822,6 +883,7 @@ 1DE19EC42C1B422F0031804A /* RecipeListViewController.swift in Sources */, 1DE19EBF2C1B422F0031804A /* RecipeDetailViewModel.swift in Sources */, 1D1283A22C15E94300C5A870 /* Recipe.swift in Sources */, + 1D0173AE2CB171DF00FF04BA /* Comment.swift in Sources */, 1D1283CA2C16D9C600C5A870 /* RecipeFetchService.swift in Sources */, 1D6958D42C3D059E008604B3 /* RecipeListRouter.swift in Sources */, 1D4741D42C1B4F8D009381CE /* NetworkResponseDTO.swift in Sources */, diff --git a/HomeCafeRecipes/HomeCafeRecipes/Data/Network/CommentService.swift b/HomeCafeRecipes/HomeCafeRecipes/Data/Network/CommentService.swift new file mode 100644 index 0000000..cf834d0 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Data/Network/CommentService.swift @@ -0,0 +1,37 @@ +// +// CommentService.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/6/24. +// + +import UIKit + +import RxSwift + +protocol CommentService { + func fetchComment(recipeID: Int) -> Single<[Comment]> +} + +final class CommentServiceImpl: CommentService { + private let networkService: NetworkService + + init(networkService: NetworkService) { + self.networkService = networkService + } + + private func makeURL(ednpoint: String) -> URL { + return APIConfig().baseURL.appendingPathComponent(ednpoint) + } + + func fetchComment(recipeID: Int) -> Single<[Comment]> { + let url = makeURL(ednpoint: "comments/\(recipeID)") + + return networkService.getRequest( + url: url, + responseType: NetworkResponseDTO<[CommentDTO]>.self + ).map { response in + response.data.map { $0.toDomain() } + } + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Data/Network/DTO/CommentDTO.swift b/HomeCafeRecipes/HomeCafeRecipes/Data/Network/DTO/CommentDTO.swift new file mode 100644 index 0000000..4297dd8 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Data/Network/DTO/CommentDTO.swift @@ -0,0 +1,41 @@ +// +// CommentDTO.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/6/24. +// + +import Foundation + +struct CommentDTO: Decodable { + + let commentID: Int + let comment: String + let commentLikeCount: Int + let isLiked: Bool + let createdAt: String + let writer: UserDTO + + enum CodingKeys: String, CodingKey { + case commentID = "commentId" + case comment = "content" + case commentLikeCount = "commentLikesCnt" + case isLiked = "isLiked" + case createdAt = "createdAt" + case writer = "writer" + } +} + +extension CommentDTO { + func toDomain() -> Comment{ + return Comment ( + commentID: commentID, + comment: comment, + commentLikeCount: commentLikeCount, + isLiked: isLiked, + createAt: DateFormatter.iso8601.date(from: createdAt) ?? Date(), + writer: writer.toDomain() + ) + + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Data/Repositories/CommentListRepository.swift b/HomeCafeRecipes/HomeCafeRecipes/Data/Repositories/CommentListRepository.swift new file mode 100644 index 0000000..abd6a26 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Data/Repositories/CommentListRepository.swift @@ -0,0 +1,26 @@ +// +// CommentListRepository.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/6/24. +// + +import Foundation + +import RxSwift + +protocol CommentListRepository { + func fetchComments(recipeID: Int) -> Single<[Comment]> +} + +final class CommentListRepositoryImpl: CommentListRepository { + private let commnetServie: CommentService + + init(commnetServie: CommentService) { + self.commnetServie = commnetServie + } + + func fetchComments(recipeID: Int) -> Single<[Comment]> { + return commnetServie.fetchComment(recipeID: recipeID) + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Domain/Entities/Comment.swift b/HomeCafeRecipes/HomeCafeRecipes/Domain/Entities/Comment.swift new file mode 100644 index 0000000..3906f34 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Domain/Entities/Comment.swift @@ -0,0 +1,17 @@ +// +// Comment.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/5/24. +// + +import Foundation + +struct Comment { + let commentID: Int + let comment: String + let commentLikeCount: Int + let isLiked: Bool + let createAt: Date + let writer: User +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Domain/Interactor/CommentInteractor.swift b/HomeCafeRecipes/HomeCafeRecipes/Domain/Interactor/CommentInteractor.swift new file mode 100644 index 0000000..02c1a2e --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Domain/Interactor/CommentInteractor.swift @@ -0,0 +1,52 @@ +// +// CommentInteractor.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/6/24. +// + +import Foundation + +import RxSwift + +protocol CommentInteractorDelegate: AnyObject { + func fetchedComments(result: Result<[Comment], Error>) +} + +protocol CommentInteractor { + func loadComment(recipeID: Int) +} + +final class CommentInteractorImpl: CommentInteractor { + private let disposeBag = DisposeBag() + private let usecase: FetchCommentUsecase + private var allComments: [Comment] = [] + weak var delegate: CommentInteractorDelegate? + + init(usecase: FetchCommentUsecase) { + self.usecase = usecase + } + + func loadComment(recipeID: Int) { + usecase.execute(recipeID: recipeID) + .subscribe(onSuccess: { [weak self] comments in + self?.handleResult(.success(comments)) + }, onError: { [weak self] error in + self?.handleResult(.failure(error)) + }) + .disposed(by: disposeBag) + } + + private func handleResult(_ result: Result<[Comment], Error>) { + switch result { + case .success(let comments): + if comments.isEmpty { + return + } + allComments = comments + delegate?.fetchedComments(result: .success(allComments)) + case .failure(let error): + delegate?.fetchedComments(result: .failure(error)) + } + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Domain/UseCases/FetchCommentUsecase.swift b/HomeCafeRecipes/HomeCafeRecipes/Domain/UseCases/FetchCommentUsecase.swift new file mode 100644 index 0000000..dcf1f7e --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Domain/UseCases/FetchCommentUsecase.swift @@ -0,0 +1,25 @@ +// +// FetchCommentUsecase.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/6/24. +// + +import RxSwift + +protocol FetchCommentUsecase{ + func execute(recipeID: Int) -> Single<[Comment]> +} + + +final class FetchCommentUsecaseImpl: FetchCommentUsecase{ + private let repository: CommentListRepository + + init(repository: CommentListRepository) { + self.repository = repository + } + + func execute(recipeID: Int) -> Single<[Comment]> { + return repository.fetchComments(recipeID: recipeID) + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailView.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailView.swift index dc035cf..c13b043 100644 --- a/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailView.swift +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailView.swift @@ -9,69 +9,152 @@ import UIKit import Kingfisher +protocol RecipeDetailViewDelegate: AnyObject { + func didTapLikeButton() + func didTapCommentButton() + func didTapBookmarkButton() +} + final class RecipeDetailView: UIView { + private let scrollView : UIScrollView = { + let scrollView = UIScrollView() + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + return scrollView + }() + + private let pageControl: UIPageControl = { + let pageControl = UIPageControl() + return pageControl + }() + + private let recipeNameLabel: UILabel = { + let recipeNameLabel = UILabel() + recipeNameLabel.font = Fonts.detailTitleFont + recipeNameLabel.numberOfLines = 0 + return recipeNameLabel + }() + + private let recipeDescriptionLabel: UILabel = { + let recipeDescriptionLabel = UILabel() + recipeDescriptionLabel.font = Fonts.detailBodyFont + recipeDescriptionLabel.numberOfLines = 0 + return recipeDescriptionLabel + }() + + private let photoIndexLabel: UILabel = { + let photoIndexLabel = UILabel() + photoIndexLabel.font = Fonts.detailBodyFont + return photoIndexLabel + }() + + private lazy var likeButton: UIButton = { + let likeButton = UIButton() + likeButton.setImage( + UIImage(systemName: "suit.heart"), + for: .normal + ) + likeButton.tintColor = .red + likeButton.addAction( + UIAction( + handler: { [weak self] _ in + self?.delegate?.didTapLikeButton() + } + ), + for: .touchUpInside + ) + return likeButton + }() + + private lazy var commentButton: UIButton = { + let commentButton = UIButton() + commentButton.setImage( + UIImage(systemName: "text.bubble"), + for: .normal + ) + commentButton.tintColor = .gray + commentButton.addAction( + UIAction( + handler: { [weak self] _ in + self?.delegate?.didTapCommentButton() + } + ), + for: .touchUpInside + ) + return commentButton + }() + + private lazy var bookmarkButton: UIButton = { + let bookmarkButton = UIButton() + bookmarkButton.setImage( + UIImage(systemName: "bookmark"), + for: .normal + ) + bookmarkButton.tintColor = .gray + bookmarkButton.addAction( + UIAction( + handler: { [weak self] _ in + self?.delegate?.didTapBookmarkButton() + } + ), + for: .touchUpInside + ) + return bookmarkButton + }() + + private lazy var actionButtonStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [likeButton, commentButton, bookmarkButton]) + stackView.axis = .horizontal + stackView.spacing = 15 + stackView.distribution = .fillEqually + return stackView + }() - let customNavigationBar = CustomNavigationBar() - private let scrollView = UIScrollView() - private let pageControl = UIPageControl() - private let recipeNameLabel = UILabel() - private let recipeDescriptionLabel = UILabel() - private let photoIndexLabel = UILabel() private var imagesAdded = false - + + weak var delegate: RecipeDetailViewDelegate? + + let customNavigationBar = CustomNavigationBar() + override init(frame: CGRect) { super.init(frame: frame) setupUI() - setupLayout() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func setupUI() { backgroundColor = .white + scrollView.delegate = self setupNavigationBar() - setupScrollView() - setupPageControl() - setupLabels() + addsubViews() + setupConstraints() } - + private func setupNavigationBar() { addSubview(customNavigationBar) customNavigationBar.translatesAutoresizingMaskIntoConstraints = false } - - private func setupScrollView() { - addSubview(scrollView) - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.isPagingEnabled = true - scrollView.showsHorizontalScrollIndicator = false - scrollView.delegate = self - } - private func setupPageControl() { + private func addsubViews() { + addSubview(scrollView) addSubview(pageControl) - pageControl.translatesAutoresizingMaskIntoConstraints = false - } - - private func setupLabels() { addSubview(recipeNameLabel) + addSubview(actionButtonStackView) addSubview(recipeDescriptionLabel) addSubview(photoIndexLabel) - + } + + private func setupConstraints() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + pageControl.translatesAutoresizingMaskIntoConstraints = false + photoIndexLabel.translatesAutoresizingMaskIntoConstraints = false + actionButtonStackView.translatesAutoresizingMaskIntoConstraints = false recipeNameLabel.translatesAutoresizingMaskIntoConstraints = false recipeDescriptionLabel.translatesAutoresizingMaskIntoConstraints = false - photoIndexLabel.translatesAutoresizingMaskIntoConstraints = false - recipeNameLabel.font = Fonts.detailTitleFont - recipeNameLabel.numberOfLines = 0 - recipeDescriptionLabel.font = Fonts.detailBodyFont - recipeDescriptionLabel.numberOfLines = 0 - photoIndexLabel.font = Fonts.detailBodyFont - } - - private func setupLayout() { NSLayoutConstraint.activate([ customNavigationBar.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), customNavigationBar.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -89,7 +172,10 @@ final class RecipeDetailView: UIView { photoIndexLabel.topAnchor.constraint(equalTo: pageControl.bottomAnchor, constant: 10), photoIndexLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - recipeNameLabel.topAnchor.constraint(equalTo: photoIndexLabel.bottomAnchor, constant: 20), + actionButtonStackView.topAnchor.constraint(equalTo: photoIndexLabel.bottomAnchor, constant: 10), + actionButtonStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + + recipeNameLabel.topAnchor.constraint(equalTo: actionButtonStackView.bottomAnchor, constant: 20), recipeNameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), recipeNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), @@ -99,7 +185,6 @@ final class RecipeDetailView: UIView { ]) } - func configure(with viewModel: RecipeDetailViewModel) { recipeNameLabel.text = viewModel.recipeName recipeDescriptionLabel.text = viewModel.recipeDescription diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailViewController.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailViewController.swift index 1ae1610..50d5188 100644 --- a/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailViewController.swift +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Feed/View/RecipeDetailViewController.swift @@ -14,13 +14,16 @@ final class RecipeDetailViewController: UIViewController { private let contentView = RecipeDetailView() private let customNavigationBar = CustomNavigationBar() private let interactor: RecipeDetailInteractor + private let router: RecipeListRouter private let disposeBag = DisposeBag() private var recipeDetailViewModel: RecipeDetailViewModel? private let recipeListMapper = RecipeListMapper() + private var recipeID: Int? - init(interactor: RecipeDetailInteractor) { + init(interactor: RecipeDetailInteractor, router: RecipeListRouter) { self.interactor = interactor - super.init(nibName: nil, bundle: nil) + self.router = router + super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { @@ -34,11 +37,19 @@ final class RecipeDetailViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() interactor.viewDidLoad() - contentView.customNavigationBar.backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + contentView.customNavigationBar.backButton.addTarget( + self, + action: #selector(backButtonTapped), + for: .touchUpInside) + contentView.delegate = self } private func displayError(_ error: Error) { - let alert = UIAlertController(title: "해당 레시피를 로드하는데 실패했습니다.", message: error.localizedDescription, preferredStyle: .alert) + let alert = UIAlertController( + title: "해당 레시피를 로드하는데 실패했습니다.", + message: error.localizedDescription, + preferredStyle: .alert + ) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } @@ -55,6 +66,7 @@ extension RecipeDetailViewController: RecipeDetailInteractorDelegate { switch result { case .success(let recipe): let recipeItemViewModel = recipeListMapper.mapToRecipeDetailViewModel(from: recipe) + self.recipeID = recipeItemViewModel.id DispatchQueue.main.async { self.contentView.configure(with: recipeItemViewModel) } @@ -66,6 +78,28 @@ extension RecipeDetailViewController: RecipeDetailInteractorDelegate { } } +// MARK: RecipeDetailViewDelegate + +extension RecipeDetailViewController: RecipeDetailViewDelegate { + func didTapLikeButton() { + // 좋아요 눌렀을 때의 행동 정의 + } + + func didTapCommentButton() { + guard let recipeID = recipeID else { + print("Recipe ID is missing") + return + } + router.presentCommentViewModally(from: self, recipeID: recipeID) + print(recipeID) + } + + func didTapBookmarkButton() { + // 북마크 눌렀을 떄의 행동정의 + } + + +} extension RecipeDetailViewController: Drawable { var viewController: UIViewController? { diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/Mapper/CommnetMapper.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Mapper/CommnetMapper.swift new file mode 100644 index 0000000..a74a7f2 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/Mapper/CommnetMapper.swift @@ -0,0 +1,15 @@ +// +// CommnetMapper.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/8/24. +// + +import Foundation + +struct CommentMapper { + static func mapToViewModels(from comments: [Comment]) -> [CommentViewModel] { + return comments.map { CommentViewModel(comment: $0) } + } + +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentCell.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentCell.swift new file mode 100644 index 0000000..8869fde --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentCell.swift @@ -0,0 +1,95 @@ +// +// CommentCell.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/5/24. +// + +import UIKit + +final class CommentCell: UITableViewCell { + static let identifier = "CommentCell" + + private let profileImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 20 + imageView.image = UIImage(named: "user") + imageView.clipsToBounds = true + return imageView + }() + + private let usernameLabel: UILabel = { + let label = UILabel() + label.font = UIFont.boldSystemFont(ofSize: 14) + return label + }() + + private let commentLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 14) + label.numberOfLines = 0 + return label + }() + + private let timeLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 12) + label.textColor = .lightGray + return label + }() + + func configure(with viewModel: CommentViewModel) { + if let profileImgUrl = viewModel.profileImgUrl { + profileImageView.loadImage(from: profileImgUrl) + } else { + profileImageView.image = UIImage(named: "EmptyImage") + } + usernameLabel.text = viewModel.username + commentLabel.text = viewModel.comment + timeLabel.text = DateFormatter.timeAgoDisplay(from: viewModel.date) + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addsubViews() + setupConstraints() + } + + private func addsubViews() { + contentView.addSubview(profileImageView) + contentView.addSubview(usernameLabel) + contentView.addSubview(commentLabel) + contentView.addSubview(timeLabel) + } + + private func setupConstraints() { + profileImageView.translatesAutoresizingMaskIntoConstraints = false + usernameLabel.translatesAutoresizingMaskIntoConstraints = false + commentLabel.translatesAutoresizingMaskIntoConstraints = false + timeLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + profileImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + profileImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), + profileImageView.widthAnchor.constraint(equalToConstant: 40), + profileImageView.heightAnchor.constraint(equalToConstant: 40), + usernameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + usernameLabel.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 10), + usernameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), + commentLabel.topAnchor.constraint(equalTo: usernameLabel.bottomAnchor, constant: 5), + commentLabel.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 10), + commentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), + timeLabel.topAnchor.constraint(equalTo: commentLabel.bottomAnchor, constant: 10), + timeLabel.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 10), + timeLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10) + ]) + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentView.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentView.swift new file mode 100644 index 0000000..0a64016 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentView.swift @@ -0,0 +1,72 @@ +// +// CommentView.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/5/24. +// + +import UIKit + +final class CommentView: UIView { + private let tableView = UITableView() + + private var comments: [CommentViewModel] = [] + + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = .white + addsubviews() + setupConstraints() + setupTableView() + } + + private func addsubviews() { + addSubview(tableView) + } + + private func setupConstraints() { + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor) + ]) + } + private func setupTableView() { + tableView.delegate = self + tableView.dataSource = self + tableView.register(CommentCell.self, forCellReuseIdentifier: CommentCell.identifier) + } + + func updateComments(_ comments: [CommentViewModel]) { + self.comments = comments + tableView.reloadData() + } +} + +// MARK: - UITableViewDelegate & UITableViewDataSource + +extension CommentView: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return comments.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: CommentCell.identifier, for: indexPath) as? CommentCell else { + return UITableViewCell() + } + let comment = comments[indexPath.row] + cell.configure(with: comment) + return cell + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentViewController.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentViewController.swift new file mode 100644 index 0000000..fa07a07 --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/CommentViewController.swift @@ -0,0 +1,70 @@ +// +// CommentViewController.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/5/24. +// + +import UIKit + +final class CommentViewController: UIViewController { + private var contentView = CommentView() + private var comments: [Comment] = [] + private var commentFetchInteractor: CommentInteractor + private let commentMapper = CommentMapper() + private let recipeID: Int + + init(commentFetchInteractor: CommentInteractor, recipeID: Int) { + self.commentFetchInteractor = commentFetchInteractor + self.recipeID = recipeID + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + commentFetchInteractor.loadComment(recipeID: recipeID) + } + + private func setupUI() { + view.backgroundColor = .white + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: view.topAnchor), + contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} + +extension CommentViewController: CommentInteractorDelegate { + func fetchedComments(result: Result<[Comment], Error>) { + switch result { + case .success(let comments): + let viewModels = CommentMapper.mapToViewModels(from: comments) + DispatchQueue.main.async { [weak self] in + self?.contentView.updateComments(viewModels) + } + case .failure(let error): + DispatchQueue.main.async { [weak self] in + let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default, handler: nil)) + self?.present(alert, animated: true, completion: nil) + } + } + } +} + +extension CommentViewController: Drawable { + var viewController: UIViewController? { + return self + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/commentViewModel.swift b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/commentViewModel.swift new file mode 100644 index 0000000..ab8d67f --- /dev/null +++ b/HomeCafeRecipes/HomeCafeRecipes/Presentation/comment/commentViewModel.swift @@ -0,0 +1,26 @@ +// +// commentViewModel.swift +// HomeCafeRecipes +// +// Created by 김건호 on 10/5/24. +// + +import Foundation + +struct CommentViewModel { + let username: String + let profileImgUrl: URL? + let comment : String + let commentLikeCnt: Int + let date: Date + let isLiked: Bool + + init(comment: Comment) { + self.username = comment.writer.nickname + self.profileImgUrl = URL(string: comment.writer.profileImage) + self.comment = comment.comment + self.commentLikeCnt = comment.commentLikeCount + self.date = comment.createAt + self.isLiked = comment.isLiked + } +} diff --git a/HomeCafeRecipes/HomeCafeRecipes/Router/RecipeListRouter.swift b/HomeCafeRecipes/HomeCafeRecipes/Router/RecipeListRouter.swift index e3988be..2d835d2 100644 --- a/HomeCafeRecipes/HomeCafeRecipes/Router/RecipeListRouter.swift +++ b/HomeCafeRecipes/HomeCafeRecipes/Router/RecipeListRouter.swift @@ -9,6 +9,7 @@ import UIKit protocol RecipeListRouter { func navigateToRecipeDetail(from viewController: UIViewController, recipeID: Int) + func presentCommentViewModally(from viewController: UIViewController, recipeID: Int) } class RecipeListRouterImpl: RecipeListRouter { @@ -22,11 +23,32 @@ class RecipeListRouterImpl: RecipeListRouter { from viewController: UIViewController, recipeID: Int ) { - let detailVC = router.makeRecipeDetailViewController(recipeID: recipeID) + let detailViewController = router.makeRecipeDetailViewController(recipeID: recipeID, router: self) router.push( - detailVC, + detailViewController, from: viewController, isAnimated: true, onNavigateBack: nil) } + + func presentCommentViewModally( + from viewController: UIViewController, + recipeID: Int + ) { + let commentViewController = router.makeCommentViewController(recipeID: recipeID) + + commentViewController.modalPresentationStyle = .pageSheet + if let sheet = commentViewController.sheetPresentationController { + let customDetent = UISheetPresentationController.Detent.custom { context in + return context.maximumDetentValue * 0.6 + } + sheet.detents = [customDetent] + } + + router.present( + commentViewController, + from: viewController, + isAnimated: true + ) + } } diff --git a/HomeCafeRecipes/HomeCafeRecipes/Router/Router.swift b/HomeCafeRecipes/HomeCafeRecipes/Router/Router.swift index 7fb65e1..41dce4a 100644 --- a/HomeCafeRecipes/HomeCafeRecipes/Router/Router.swift +++ b/HomeCafeRecipes/HomeCafeRecipes/Router/Router.swift @@ -20,6 +20,12 @@ public protocol RouterProtocol { isAnimated: Bool, onNavigateBack closure: NavigationBackClosure? ) + + func present( + _ drawable: Drawable, + from viewController: UIViewController, + isAnimated: Bool, + completion: (() -> Void)?) } class Router: NSObject, RouterProtocol { @@ -41,6 +47,18 @@ class Router: NSObject, RouterProtocol { viewController.navigationController?.pushViewController(targetViewController, animated: isAnimated) } + func present( + _ drawable: Drawable, + from viewController: UIViewController, + isAnimated: Bool, + completion: (() -> Void)? = nil + ) { + guard let targetViewController = drawable.viewController else { + return + } + viewController.present(targetViewController, animated: isAnimated, completion: completion) + } + private func executeClosure(_ viewController: UIViewController) { guard let closure = closures.removeValue(forKey: viewController.description) else { return } closure() @@ -92,7 +110,7 @@ extension Router { return addRecipeVC } - func makeRecipeDetailViewController(recipeID: Int) -> RecipeDetailViewController { + func makeRecipeDetailViewController(recipeID: Int, router: RecipeListRouter) -> RecipeDetailViewController { let detailInteractor = RecipeDetailInteractorImpl( fetchRecipeDetailUseCase: FetchRecipeDetailUseCaseImpl( repository: RecipeDetailRepositoryImpl( @@ -101,11 +119,32 @@ extension Router { ), recipeID: recipeID ) - let detailVC = RecipeDetailViewController(interactor: detailInteractor) + + let detailVC = RecipeDetailViewController(interactor: detailInteractor, router: router) detailInteractor.delegate = detailVC return detailVC } + func makeCommentViewController(recipeID: Int) -> CommentViewController { + let commentInteractor = CommentInteractorImpl( + usecase: FetchCommentUsecaseImpl( + repository: CommentListRepositoryImpl( + commnetServie: CommentServiceImpl( + networkService: BaseNetworkService() + ) + ) + ) + ) + + let commentViewController = CommentViewController( + commentFetchInteractor: commentInteractor, + recipeID: recipeID + ) + + commentInteractor.delegate = commentViewController + return commentViewController + } + func makeLoginViewController() -> LoginViewController { let loginInteractor = LoginInteractorImpl( loginUseCase: LoginUseCaseImpl( diff --git a/HomeCafeRecipes/HomeCafeRecipes/Utilities/DateFormatter+Extensions.swift b/HomeCafeRecipes/HomeCafeRecipes/Utilities/DateFormatter+Extensions.swift index 26cf6d6..28d48d8 100644 --- a/HomeCafeRecipes/HomeCafeRecipes/Utilities/DateFormatter+Extensions.swift +++ b/HomeCafeRecipes/HomeCafeRecipes/Utilities/DateFormatter+Extensions.swift @@ -16,4 +16,18 @@ extension DateFormatter { formatter.locale = Locale(identifier: "en_US_POSIX") return formatter }() + + static func timeAgoDisplay(from date: Date) -> String { + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.hour, .day], from: date, to: now) + + if let day = components.day, day >= 1 { + return "\(day)일 전" + } else if let hour = components.hour, hour >= 1 { + return "\(hour)시간 전" + } else { + return "방금 전" + } + } }