diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/BeforeSelectedImageDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/BeforeSelectedImageDTO.swift index 9fb4a2d0..604c58e6 100644 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/BeforeSelectedImageDTO.swift +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/BeforeSelectedImageDTO.swift @@ -2,21 +2,12 @@ // BeforeSelectedImageDTO.swift // EAT-SSU // -// Created by 박윤빈 on 3/7/24. +// Created by 한금준 on 28/11/25. // import Foundation struct BeforeSelectedImageDTO: Codable { let mainRating: Int - let amountRating: Int? - let tasteRating: Int? let content: String - - init(mainRating: Int, amountRating: Int?, tasteRating: Int?, content: String) { - self.mainRating = mainRating - self.amountRating = amountRating - self.tasteRating = tasteRating - self.content = content - } } diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRateResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRateResponse.swift deleted file mode 100644 index 439a3ca7..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRateResponse.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// FixedReviewRateResponse.swift -// EAT-SSU -// -// Created by 박윤빈 on 3/18/24. -// - -import Foundation - -// MARK: - FixedReviewRateResponse - -struct FixedReviewRateResponse: Codable { - let menuName: String - let totalReviewCount: Int - let mainRating, amountRating, tasteRating: Double? - let reviewRatingCount: StarCount -} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRequestDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRequestDTO.swift new file mode 100644 index 00000000..aede13a6 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/FixedReviewRequestDTO.swift @@ -0,0 +1,14 @@ +// +// FixedReviewRequestDTO.swift +// EATSSU +// +// Created by 한금준 on 11/24/25. +// + +import Foundation + +struct FixedReviewRequestDTO: Encodable { + let rating: Int + let menuLikes: [MenuLike] + let content: String +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/MenuInfoResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/MenuInfoResponse.swift deleted file mode 100644 index 840c536e..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/MenuInfoResponse.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// MenuInfoResponse.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/18. -// - -import Foundation - -struct MenuInfoResponse: Codable {} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/MenuReviewResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/MenuReviewResponse.swift deleted file mode 100644 index 871bd6bc..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/MenuReviewResponse.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MenuReviewResponse.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/18. -// - -import Foundation - -struct MenuReviewResponse: Codable { - let numberOfElements: Int - let hasNext: Bool - let dataList: [DataList]? -} - -struct DataList: Codable { - let writerId: Int - let writerNickname: String - let grade: Int - let writeDate, content: String - let tagList: [String] - let imgUrlList: [String] -} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/MyReviewResponseDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/MyReviewResponseDTO.swift new file mode 100644 index 00000000..ae00eda9 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/MyReviewResponseDTO.swift @@ -0,0 +1,36 @@ +// +// MyReviewResponseDTO.swift +// EATSSU +// +// Created by 한금준 on 11/29/25. +// + +import Foundation + +// MARK: - Review List DTO + +/// 리뷰 리스트 데이터 컨테이너 (NetworkService의 result로 전달됨) +struct MyReviewResponseDTO: Codable { + let numberOfElements: Int + let hasNext: Bool + let dataList: [MyReviewListItem] +} + +// MARK: - Review Item DTO + +/// 개별 리뷰 아이템 구조 +struct MyReviewListItem: Codable { + let reviewId: Int + let rating: Int? + let writtenAt: String + let content: String? + let imageUrls: [String] + let menuList: [ReviewMenu] +} + +/// 리뷰에 포함된 개별 메뉴 구조 +struct ReviewMenu: Codable { + let id: Int + let name: String + let isLike: Bool +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/NewReviewListResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/NewReviewListResponse.swift new file mode 100644 index 00000000..3c5670a8 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/NewReviewListResponse.swift @@ -0,0 +1,100 @@ +// +// NewReviewListResponse.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +/// 리뷰 V2 리스트 조회 API +struct NewReviewListResponse: Codable { + let numberOfElements: Int? + let hasNext: Bool + let dataList: [ReviewListItem] +} + +struct ReviewListItem: Codable { + let reviewId: Int + var menu: [ReviewMenuInfo]? // 항상 배열로 저장 + let writerId: Int? + let isWriter: Bool + let writerNickname: String + let rating: Double + let writtenAt: String + let content: String? + /// 유효한 이미지 URL 문자열만 담는 배열 (null / 빈 문자열은 필터링) + let imageUrls: [String] + + enum CodingKeys: String, CodingKey { + case reviewId + case menu + case menuList + case writerId + case isWriter + case writerNickname + case rating + case writtenAt + case content + case imageUrls + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + reviewId = try container.decode(Int.self, forKey: .reviewId) + writerId = try container.decodeIfPresent(Int.self, forKey: .writerId) + isWriter = try container.decode(Bool.self, forKey: .isWriter) + writerNickname = try container.decode(String.self, forKey: .writerNickname) + rating = try container.decode(Double.self, forKey: .rating) + writtenAt = try container.decode(String.self, forKey: .writtenAt) + content = try container.decodeIfPresent(String.self, forKey: .content) + + // menu 처리: Fixed 메뉴(객체)와 Variable 메뉴(배열) 모두 처리 + if let menuArray = try? container.decodeIfPresent([ReviewMenuInfo].self, forKey: .menuList) { + // Variable 메뉴: menuList가 배열로 들어오는 경우 + menu = menuArray + } else if let singleMenu = try? container.decodeIfPresent(ReviewMenuInfo.self, forKey: .menu) { + // Fixed 메뉴: menu가 단일 객체로 들어오는 경우 -> 배열로 변환 + menu = [singleMenu] + } else { + menu = nil + } + + // imageUrls: [String?] 형태로 받아서 nil / 빈 문자열을 제거해 [String]으로 정제 + let rawImageUrls = try container.decodeIfPresent([String?].self, forKey: .imageUrls) ?? [] + imageUrls = rawImageUrls.compactMap { url in + guard let url, url.isEmpty == false else { return nil } + return url + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(reviewId, forKey: .reviewId) + try container.encodeIfPresent(menu, forKey: .menuList) + try container.encodeIfPresent(writerId, forKey: .writerId) + try container.encode(isWriter, forKey: .isWriter) + try container.encode(writerNickname, forKey: .writerNickname) + try container.encode(rating, forKey: .rating) + try container.encode(writtenAt, forKey: .writtenAt) + try container.encodeIfPresent(content, forKey: .content) + try container.encode(imageUrls, forKey: .imageUrls) + } +} + +struct ReviewMenuInfo: Codable { + let menuId: Int + let name: String + let isLike: Bool + + enum CodingKeys: String, CodingKey { + case menuId = "id" + case name + case isLike + } +} + +struct Tag: Codable { + let name: String + let isLiked: Bool +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewListResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewListResponse.swift deleted file mode 100644 index eadbb663..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewListResponse.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// ReviewListResponse.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/08/01. -// - -import Foundation - -struct ReviewListResponse: Codable { - let numberOfElements: Int - let hasNext: Bool - let dataList: [MenuDataList] -} - -// MARK: - DataList - -struct MenuDataList: Codable { - let reviewID: Int - let menu: String - let writerID: Int? - let isWriter: Bool - let writerNickname: String - let mainRating: Int - let amountRating, tasteRating: Int? - let writedAt, content: String - let imgURLList: [String?] - - enum CodingKeys: String, CodingKey { - case reviewID = "reviewId" - case menu - case writerID = "writerId" - case isWriter, writerNickname, mainRating, amountRating, tasteRating, writedAt, content - case imgURLList = "imageUrls" - } -} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMealStatisticsResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMealStatisticsResponse.swift new file mode 100644 index 00000000..9e329a0a --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMealStatisticsResponse.swift @@ -0,0 +1,28 @@ +// +// ReviewMealStatisticsResponse.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +// 리뷰V2 api +struct ReviewMealStatisticsResponse: Codable { + let menuList: [MenuInfo] + let totalReviewCount: Int + let rating: Double? + let likeCount: Int? + let reviewRatingCount: ReviewRatingCount +} + +struct MenuInfo: Codable { + let id: Int + let name: String +} + +struct ReviewMealRatingCount: Codable { + let oneStarCount: Int + let twoStarCount: Int + let threeStarCount: Int + let fourStarCount: Int + let fiveStarCount: Int +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMenuStatisticsResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMenuStatisticsResponse.swift new file mode 100644 index 00000000..9a136ca1 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewMenuStatisticsResponse.swift @@ -0,0 +1,23 @@ +// +// ReviewMenuStatistics.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +// 리뷰V2 api +struct ReviewMenuStatisticsResponse: Codable { + let menuName: String + let totalReviewCount: Int + let rating: Double? + let likeCount: Int? + let reviewRatingCount: ReviewRatingCount +} + +struct ReviewRatingCount: Codable { + let oneStarCount: Int + let twoStarCount: Int + let threeStarCount: Int + let fourStarCount: Int + let fiveStarCount: Int +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewRateResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewRateResponse.swift deleted file mode 100644 index d5a306ab..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewRateResponse.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ReviewRateResponse.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/07/29. -// - -import Foundation - -struct ReviewRateResponse: Codable { - let menuNames: [String] - let totalReviewCount: Int - let mainRating: Double? - let amountRating: Double? - let tasteRating: Double? - let reviewRatingCount: StarCount -} - -struct StarCount: Codable { - let fiveStarCount: Int - let fourStarCount: Int - let threeStarCount: Int - let twoStarCount: Int - let oneStarCount: Int -} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewValidMenuResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewValidMenuResponse.swift new file mode 100644 index 00000000..4b194f37 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/ReviewValidMenuResponse.swift @@ -0,0 +1,16 @@ +// +// ReviewValidMenuResponse.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +// 리뷰V2 식단 id를 통해 리뷰 작성할 수 있는 메뉴들 조회 +struct ReviewValidMenusResponse: Codable { + let menuList: [ReviewValidMenu] +} + +struct ReviewValidMenu: Codable { + let menuId: Int + let name: String +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/TotalReviewResponse.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/TotalReviewResponse.swift deleted file mode 100644 index e3a80265..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/TotalReviewResponse.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// TotalReviewResponse.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/22. -// - -// import Foundation -// -// struct TotalReviewResponse: Codable { -// let menuName: String -// let totalReviewCount: Int -// let grade: Double -// let reviewGradeCnt: StarCount -// } -// -// struct StarCount: Codable { -// let fiveCnt: Int -// let fourCnt: Int -// let threeCnt: Int -// let twoCnt: Int -// let oneCnt: Int -// } diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMealRequest.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMealRequest.swift new file mode 100644 index 00000000..40f2224b --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMealRequest.swift @@ -0,0 +1,25 @@ +// +// WriteReviewMealRequest.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +// 리뷰v2 api +struct WriteReviewMealRequest: Encodable { + let mealId: Int + let rating: Int + let menuLikes: [MenuLike] + let content: String? + let imageUrls: [String]? +} + +struct MenuLike: Encodable { + let menuId: Int + let isLike: Bool + + private enum CodingKeys: String, CodingKey { + case menuId + case isLike + } +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMenuRequest.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMenuRequest.swift new file mode 100644 index 00000000..ba4f75c3 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewMenuRequest.swift @@ -0,0 +1,14 @@ +// +// WriteReviewMenuRequest.swift +// EATSSU +// +// Created by 한금준 on 11/16/25. +// + +// 리뷰v2 api +struct WriteReviewMenuRequest: Encodable { + let rating: Int + let menuLike: MenuLike + let content: String? + let imageUrls: [String]? +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewRequest.swift b/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewRequest.swift deleted file mode 100644 index c6760a41..00000000 --- a/EATSSU/App/Sources/Data/Network/DTO/Review/WriteReviewRequest.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// WriteReviewRequest.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/24. -// - -import UIKit - -struct WriteReviewRequest: Codable { - let mainRating: Int - let amountRating: Int? - let tasteRating: Int? - let content: String - let imageUrl: String - - init(mainRating: Int, amountRating: Int, tasteRating: Int, content: String, imageURL: String?) { - self.mainRating = mainRating - self.amountRating = amountRating - self.tasteRating = tasteRating - self.content = content - imageUrl = imageURL ?? "" - } - - init(content: BeforeSelectedImageDTO, imageURL: String?) { - mainRating = content.mainRating - amountRating = content.amountRating - tasteRating = content.tasteRating - self.content = content.content - imageUrl = imageURL ?? "" - } -} diff --git a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift index 646176a4..1acc3b9b 100644 --- a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift @@ -10,7 +10,10 @@ import UIKit import Moya enum MyRouter { - case myReview + case getMyReviewList(lastReviewId: Int?, + page: Int? = 0, + size: Int? = 20, + sort: String? = "date,DESC") case myInfo case signOut case inquiry(param: InquiryRequest) @@ -27,8 +30,8 @@ extension MyRouter: TargetType { var path: String { switch self { - case .myReview: - "/users/reviews" + case .getMyReviewList: + "users/v2/reviews" case .myInfo: "/users/mypage" case .signOut: @@ -48,7 +51,7 @@ extension MyRouter: TargetType { var method: Moya.Method { switch self { - case .myReview, .departments, .colleges: + case .getMyReviewList, .departments, .colleges: .get case .myInfo, .getDepartment, .getMyPartnerships: .get @@ -61,11 +64,23 @@ extension MyRouter: TargetType { var task: Moya.Task { switch self { - case .myReview: - .requestParameters(parameters: ["page": 0, - "size": 20, - "sort": "date,DESC"], - encoding: URLEncoding.queryString) + case let .getMyReviewList(lastReviewId, page, size, sort): + .requestParameters( + parameters: { + var dict: [String: Any] = [:] + + if let lastId = lastReviewId { + dict["lastReviewId"] = lastId + } else { + dict["page"] = page ?? 0 + } + + dict["size"] = size ?? 20 + dict["sort"] = sort ?? "date,DESC" + return dict + }(), + encoding: URLEncoding.queryString + ) case .myInfo: .requestPlain case .signOut: diff --git a/EATSSU/App/Sources/Data/Network/Router/ReviewRouter.swift b/EATSSU/App/Sources/Data/Network/Router/ReviewRouter.swift index c31a91a5..6a72614f 100644 --- a/EATSSU/App/Sources/Data/Network/Router/ReviewRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/ReviewRouter.swift @@ -9,97 +9,104 @@ import Foundation import Moya enum ReviewRouter { - // 상단 메뉴 별점 불러오는 API -> 두개로 쪼개짐. 고정, 변동 분기처리는 아래에서! - case reviewRate(_ type: String, _ id: Int) - - // 하단 리뷰 리스트 불러오는 API - case reviewList(_ type: String, _ id: Int) case report(param: ReportRequest) case deleteReview(_ reviewId: Int) - case fixReview(_ reviewId: Int, _ param: BeforeSelectedImageDTO) + + // MARK: - New V2 API + case getValidMenusForReview(_ mealId: Int) + case newReviewList(_ type: String, + _ id: Int, + lastReviewId: Int?, + page: Int? = 0, + size: Int? = 20) + case getFixedMenuStatistics(_ menuId: Int) + case getMealStatistics(_ mealId: Int) } extension ReviewRouter: TargetType { var baseURL: URL { URL(string: Config.baseURL)! } - + var path: String { switch self { - case let .reviewRate(type, id): + case .report: + "/reports" + case let .deleteReview(reviewId): + "/v2/reviews/\(reviewId)" + // MARK: - New V2 Path + case let .getValidMenusForReview(mealId): + "/v2/reviews/meal/valid-for-review/\(mealId)" + case .newReviewList(let type, _, _, _, _): switch type { case "VARIABLE": - "/reviews/meals/\(id)" + "/v2/reviews/list/meal" case "FIXED": - "/reviews/menus/\(id)" + "/v2/reviews/list/menu" default: "" } - case .reviewList: - "/reviews" - case .report: - "/reports" - case let .deleteReview(reviewId): - "/reviews/\(reviewId)" - case let .fixReview(reviewId, _): - "/reviews/\(reviewId)" + case let .getFixedMenuStatistics(menuId): + "/v2/reviews/statistics/menus/\(menuId)" + case let .getMealStatistics(mealId): + "/v2/reviews/statistics/meals/\(mealId)" } } - + var method: Moya.Method { switch self { - case .reviewRate: - .get - case .reviewList: - .get + case .getValidMenusForReview, .newReviewList, .getFixedMenuStatistics, .getMealStatistics: + .get case .report: - .post + .post case .deleteReview: - .delete - case .fixReview: - .patch + .delete } } - + var task: Moya.Task { switch self { - case let .reviewRate(type, id): - switch type { - case "VARIABLE": - .requestParameters(parameters: ["mealId": id], - encoding: URLEncoding.queryString) - case "FIXED": - .requestParameters(parameters: ["menuId": id], - encoding: URLEncoding.queryString) - default: + case let .report(param: param): + .requestJSONEncodable(param) + case .deleteReview: .requestPlain - } - /// 이후 정렬 순서, 리뷰 로드 개수 등 수정 필요하면 고치기 - case let .reviewList(type, id): + + // MARK: - New V2 Task + case .getValidMenusForReview: + .requestPlain + case let .newReviewList(type, id, lastReviewId, page, size): switch type { case "VARIABLE": - .requestParameters(parameters: ["menuType": type, - "mealId": id, - "page": 0, - "size": 20, - "sort": "date,DESC"], - encoding: URLEncoding.queryString) + .requestParameters( + parameters: { + var dict: [String: Any] = [ + "mealId": id, + "size": size ?? 20 + ] + + if let lastId = lastReviewId { + dict["lastReviewId"] = lastId + } + + return dict + }(), + encoding: URLEncoding.queryString + ) + case "FIXED": - .requestParameters(parameters: ["menuType": type, - "menuId": id, - "page": 0, - "size": 20, - "sort": "date,DESC"], - encoding: URLEncoding.queryString) + .requestParameters( + parameters: ["menuId": id, "page": page ?? 0, "size": size ?? 20], + encoding: URLEncoding.queryString + ) + default: - .requestPlain + .requestPlain + } - case let .report(param: param): - .requestJSONEncodable(param) - case .deleteReview: - .requestPlain - case let .fixReview(_, param): - .requestJSONEncodable(param) + case .getFixedMenuStatistics: + .requestPlain + case .getMealStatistics: + .requestPlain } } diff --git a/EATSSU/App/Sources/Data/Network/Router/WriteReviewRouter.swift b/EATSSU/App/Sources/Data/Network/Router/WriteReviewRouter.swift index dd2e4756..4b8114a8 100644 --- a/EATSSU/App/Sources/Data/Network/Router/WriteReviewRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/WriteReviewRouter.swift @@ -11,59 +11,42 @@ import Moya enum WriteReviewRouter { case uploadImage(image: UIImage?) - case writeNewReview(param: WriteReviewRequest, menuID: Int) - case writeReview(param: WriteReviewRequest, image: [UIImage?], menuId: Int) + // MARK: - New V2 APIs + case writeMenuReview(param: WriteReviewMenuRequest) + case writeMealReview(param: WriteReviewMealRequest) + case fixReview(reviewId: Int, param: FixedReviewRequestDTO) } extension WriteReviewRouter: TargetType { var baseURL: URL { URL(string: Config.baseURL)! } - + var path: String { switch self { - case .writeReview(param: _, image: _, menuId: let menuId): - "/reviews/\(menuId)" case .uploadImage: "/reviews/upload/image" - case .writeNewReview(param: _, menuID: let menuId): - "/reviews/write/\(menuId)" + // MARK: - New V2 Paths + case .writeMenuReview: + "/v2/reviews/menu" + case .writeMealReview: + "/v2/reviews/meal" + case .fixReview(reviewId: let reviewId, param: _): + "/v2/reviews/\(reviewId)" } } - + var method: Moya.Method { switch self { - case .writeReview, .uploadImage, .writeNewReview: - .post + case .uploadImage, .writeMenuReview, .writeMealReview: + .post + case .fixReview: + .patch } } - + var task: Moya.Task { switch self { - case .writeReview(param: let param, image: let imageList, menuId: _): - var multipartData = [MultipartFormData]() - do { - let jsonData = try JSONEncoder().encode(param) - multipartData.append(MultipartFormData(provider: .data(jsonData), - name: "createReviewRequest", - mimeType: "application/json")) - } catch { - print("Error encoding ReviewRequest: \(error)") - return .requestPlain - } - - for fileData in imageList { - if let unwrappedImage = fileData { - if let imageData = unwrappedImage.resize(newWidth: 300.adjusted).jpegData(compressionQuality: 0.3) { - multipartData.append(MultipartFormData(provider: .data(imageData), - name: "multipartFileList", - fileName: "image.jpg", - mimeType: "image/jpeg")) - } - } - } - return .uploadMultipart(multipartData) - case let .uploadImage(image: image): var multipartData = [MultipartFormData]() guard let unwrappedImage = image else { return .requestPlain } @@ -74,17 +57,22 @@ extension WriteReviewRouter: TargetType { mimeType: "image/jpeg")) } return .uploadMultipart(multipartData) - - case let .writeNewReview(param: param, _): + + // MARK: - New V2 Tasks (JSON Encoded) + case let .writeMenuReview(param: param): + return .requestJSONEncodable(param) + case let .writeMealReview(param: param): + return .requestJSONEncodable(param) + case let .fixReview(reviewId: _, param: param): return .requestJSONEncodable(param) } } - + var headers: [String: String]? { switch self { - case .writeNewReview: + case .writeMenuReview, .writeMealReview, .fixReview: return ["Content-Type": "application/json"] - case .uploadImage, .writeReview: + case .uploadImage: return ["Content-Type": "multipart/form-data"] } } diff --git a/EATSSU/App/Sources/Presentation/Home/View/RestaurantTableView/RestaurantMenuItemView.swift b/EATSSU/App/Sources/Presentation/Home/View/RestaurantTableView/RestaurantMenuItemView.swift index f34b9ebc..c26ba49b 100644 --- a/EATSSU/App/Sources/Presentation/Home/View/RestaurantTableView/RestaurantMenuItemView.swift +++ b/EATSSU/App/Sources/Presentation/Home/View/RestaurantTableView/RestaurantMenuItemView.swift @@ -90,7 +90,7 @@ final class RestaurantMenuItemView: BaseUIView { func bind(_ model: MenuTypeInfo) { switch model { case let .change(data): - nameLabel.text = data.briefMenus.map(\.name).joined(separator: "+") + nameLabel.text = data.briefMenus.map(\.name).joined(separator: ", ") priceLabel.text = data.price?.formattedWithCommas ?? "" ratingLabel.text = data.rating != nil ? String(format: "%.1f", data.rating!) : "-" case let .fix(data): diff --git a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift index 3245806c..5ba3aa20 100644 --- a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift +++ b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift @@ -264,11 +264,25 @@ extension HomeRestaurantViewController: UITableViewDataSource { reviewMenuTypeInfo.menuID = menus[menuIndex].menuId reviewMenuTypeInfo.changeMenuIDList = nil } - + let reviewViewController = ReviewViewController() + + // 상위 부모에서 CustomTabBarContainerController 찾기 + var parentVC = self.parent + while parentVC != nil { + if let customTabBar = parentVC as? CustomTabBarContainerController { + customTabBar.setTabBarHidden(true, animated: false) + break + } + parentVC = parentVC?.parent + } + + // delegate 연결 delegate = reviewViewController - navigationController?.pushViewController(reviewViewController, animated: true) delegate?.didDelegateReviewMenuTypeInfo(for: reviewMenuTypeInfo) + + // push로 띄우기 + navigationController?.pushViewController(reviewViewController, animated: true) } private func presentLoginAlert() { diff --git a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeViewController.swift b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeViewController.swift index c60c9467..305fec3f 100644 --- a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeViewController.swift +++ b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeViewController.swift @@ -59,6 +59,8 @@ final class HomeViewController: BaseViewController { navigationController?.setNavigationBarHidden(true, animated: animated) addNewDayObserver() + + self.tabBarController?.tabBar.isHidden = false } override func viewWillDisappear(_ animated: Bool) { diff --git a/EATSSU/App/Sources/Presentation/MyPage/View/MyReviewView.swift b/EATSSU/App/Sources/Presentation/MyPage/View/MyReviewView.swift index d4a0432a..8364317f 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/View/MyReviewView.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/View/MyReviewView.swift @@ -6,13 +6,27 @@ // import UIKit - import SnapKit final class MyReviewView: BaseUIView { // MARK: - UI Components - let myReviewTableView = UITableView() + /// 리뷰 목록 테이블뷰 + let myReviewTableView: UITableView = { + let tableView = UITableView() + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.backgroundColor = .white + return tableView + }() + + /// 빈 상태 이미지뷰 (필요한 경우) + let noReviewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + return imageView + }() // MARK: - Life Cycles @@ -23,15 +37,21 @@ final class MyReviewView: BaseUIView { // MARK: - Functions override func configureUI() { - addSubview(myReviewTableView) - - myReviewTableView.separatorStyle = .none - myReviewTableView.showsVerticalScrollIndicator = false + backgroundColor = .white + + addSubviews(myReviewTableView, noReviewImageView) } override func setLayout() { myReviewTableView.snp.makeConstraints { - $0.edges.equalToSuperview() + $0.top.equalToSuperview().offset(24) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + + noReviewImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.height.equalTo(200) } } } diff --git a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift index 9d840ef9..51af1398 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyReviewViewController.swift @@ -13,11 +13,11 @@ import FirebaseAnalytics final class MyReviewViewController: BaseViewController { override var shouldHideTabBar: Bool { true } + // MARK: - Properties - private var reviewList = [MyDataList]() + private var reviewList = [MyReviewListItem]() var nickname: String = .init() - private var menuName: String = .init() // MARK: - UI Components @@ -56,6 +56,7 @@ final class MyReviewViewController: BaseViewController { } override func configureUI() { + view.backgroundColor = .white view.addSubviews(myReviewView) } @@ -66,8 +67,14 @@ final class MyReviewViewController: BaseViewController { } private func setDelegate() { - myReviewView.myReviewTableView.register(ReviewTableCell.self, forCellReuseIdentifier: ReviewTableCell.identifier) - myReviewView.myReviewTableView.register(ReviewEmptyViewCell.self, forCellReuseIdentifier: ReviewEmptyViewCell.identifier) + myReviewView.myReviewTableView.register( + ReviewTableCell.self, + forCellReuseIdentifier: ReviewTableCell.identifier + ) + myReviewView.myReviewTableView.register( + ReviewEmptyViewCell.self, + forCellReuseIdentifier: ReviewEmptyViewCell.identifier + ) myReviewView.myReviewTableView.delegate = self myReviewView.myReviewTableView.dataSource = self } @@ -76,28 +83,51 @@ final class MyReviewViewController: BaseViewController { self.nickname = nickname } - private func showFixOrDeleteAlert(reviewID: Int, menuName: String) { - let alert = UIAlertController(title: "리뷰 수정 혹은 삭제", - message: "작성하신 리뷰를 수정 또는 삭제하시겠습니까?", - preferredStyle: UIAlertController.Style.actionSheet) + private func showFixOrDeleteAlert(reviewID: Int, reviewItem: MyReviewListItem) { + let alert = UIAlertController( + title: "리뷰 수정 혹은 삭제", + message: "작성하신 리뷰를 수정 또는 삭제하시겠습니까?", + preferredStyle: UIAlertController.Style.actionSheet + ) - let fixAction = UIAlertAction(title: "수정하기", - style: .default, - handler: { _ in - let setRateViewController = SetRateViewController() - setRateViewController.dataBindForFix(list: [menuName], reivewId: reviewID) - self.navigationController?.pushViewController(setRateViewController, animated: true) - }) + let fixAction = UIAlertAction( + title: "수정하기", + style: .default, + handler: { _ in + let setRateViewController = SetRateViewController() + + // ✅ 모든 메뉴 정보 전달 + let menuNames = reviewItem.menuList.map { $0.name } + let menuIds = reviewItem.menuList.map { $0.id } + let likedMenuIds = reviewItem.menuList.filter { $0.isLike }.map { $0.id } + + setRateViewController.dataBindForFix( + list: menuNames, + reviewId: reviewID, + rating: reviewItem.rating, + content: reviewItem.content, + imageUrls: reviewItem.imageUrls, + menuIds: menuIds, + likedMenuIds: likedMenuIds + ) + + self.navigationController?.pushViewController(setRateViewController, animated: true) + } + ) - let deleteAction = UIAlertAction(title: "삭제하기", - style: .default, - handler: { _ in - self.deleteReview(reviewID: reviewID) - }) + let deleteAction = UIAlertAction( + title: "삭제하기", + style: .default, + handler: { _ in + self.deleteReview(reviewID: reviewID) + } + ) - let cancelAction = UIAlertAction(title: "취소하기", - style: .cancel, - handler: nil) + let cancelAction = UIAlertAction( + title: "취소하기", + style: .cancel, + handler: nil + ) alert.addAction(fixAction) alert.addAction(deleteAction) @@ -136,19 +166,31 @@ extension MyReviewViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if reviewList.isEmpty { - let cell = tableView.dequeueReusableCell(withIdentifier: ReviewEmptyViewCell.identifier, for: indexPath) as? ReviewEmptyViewCell ?? ReviewEmptyViewCell() + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewEmptyViewCell.identifier, + for: indexPath + ) as? ReviewEmptyViewCell ?? ReviewEmptyViewCell() cell.configureForMyReview() cell.selectionStyle = .none return cell } - let cell = tableView.dequeueReusableCell(withIdentifier: ReviewTableCell.identifier, for: indexPath) as? ReviewTableCell ?? ReviewTableCell() - cell.myPageDataBind(response: reviewList[indexPath.row], nickname: nickname) + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewTableCell.identifier, + for: indexPath + ) as? ReviewTableCell ?? ReviewTableCell() + + let reviewItem = reviewList[indexPath.row] + cell.myPageDataBind(response: reviewItem, nickname: nickname) + cell.handler = { [weak self] in guard let self else { return } - menuName = reviewList[indexPath.row].menuName - showFixOrDeleteAlert(reviewID: cell.reviewId, - menuName: menuName) + + // ✅ reviewItem 전체를 전달 + self.showFixOrDeleteAlert( + reviewID: reviewItem.reviewId, + reviewItem: reviewItem + ) } cell.selectionStyle = .none return cell @@ -160,8 +202,8 @@ extension MyReviewViewController: UITableViewDataSource { extension MyReviewViewController { private func getMyReview() { NetworkService.shared.request( - MyRouter.myReview, - responseType: MyReviewResponse.self, + MyRouter.getMyReviewList(lastReviewId: nil, page: 0, size: 20), + responseType: MyReviewResponseDTO.self, useAuth: true ) { [weak self] result in guard let self = self else { return } @@ -171,6 +213,9 @@ extension MyReviewViewController { self.reviewList = response.dataList self.myReviewView.myReviewTableView.reloadData() + // 빈 상태 이미지 표시 여부 + self.myReviewView.noReviewImageView.isHidden = !self.reviewList.isEmpty + case .failure(let error): print("내 리뷰 조회 실패: \(error.localizedDescription)") RealmService.shared.resetDB() @@ -179,7 +224,6 @@ extension MyReviewViewController { } } - // 리뷰 삭제 알람 추가 func deleteReview(reviewID: Int) { showCustomDialog( title: "리뷰 삭제하기", @@ -197,6 +241,7 @@ extension MyReviewViewController { switch result { case .success: self.getMyReview() + self.showToast(message: "리뷰가 성공적으로 삭제되었습니다.") case .failure(let error): print("리뷰 삭제 실패: \(error.localizedDescription)") RealmService.shared.resetDB() diff --git a/EATSSU/App/Sources/Presentation/Review/View/ChoiceMenuView/ChoiceMenuTableViewCell.swift b/EATSSU/App/Sources/Presentation/Review/View/ChoiceMenuView/ChoiceMenuTableViewCell.swift deleted file mode 100644 index 884913c3..00000000 --- a/EATSSU/App/Sources/Presentation/Review/View/ChoiceMenuView/ChoiceMenuTableViewCell.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// ChoiceMenuTableViewCell.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/06/29. -// - -import UIKit - -import SnapKit - -import EATSSUDesign - -final class ChoiceMenuTableViewCell: UITableViewCell { - // MARK: - Properties - - static let identifier = "ChoiceMenuTableViewCell" - var isChecked: Bool = false { - didSet { - tapped() - } - } - - var handler: (() -> Void)? - - // MARK: - UI Components - - lazy var checkButton = UIButton() - private lazy var menuLabel = UILabel() - - // MARK: - init - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, - reuseIdentifier: reuseIdentifier) - backgroundColor = .white - setUI() - setLayout() - } - - // MARK: - Functions - - private func setUI() { - checkButton.addTarget(self, action: #selector(checkButtonIsTapped), for: .touchUpInside) - - menuLabel.font = EATSSUDesignFontFamily.Pretendard.medium.font(size: 16) - menuLabel.textColor = .black - menuLabel.text = "고구마치즈돈까스" - - contentView.addSubviews( - checkButton, - menuLabel - ) - } - - private func setLayout() { - checkButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().inset(27) - $0.height.width.equalTo(24) - } - - menuLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(checkButton.snp.trailing).offset(15) - } - } - - @objc - func checkButtonIsTapped() { - handler?() - } -} - -extension ChoiceMenuTableViewCell { - func dataBind(menu: String, isTapped: Bool) { - menuLabel.text = menu - isChecked = isTapped - } - - func tapped() { - let image = isChecked ? EATSSUDesignAsset.Images.icCheck.image : EATSSUDesignAsset.Images.icUncheck.image - checkButton.setImage(image, for: .normal) - } -} diff --git a/EATSSU/App/Sources/Presentation/Review/View/RateReview/MenuLikeCell.swift b/EATSSU/App/Sources/Presentation/Review/View/RateReview/MenuLikeCell.swift new file mode 100644 index 00000000..dc5df64e --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Review/View/RateReview/MenuLikeCell.swift @@ -0,0 +1,154 @@ +// +// MenuLikeCell.swift +// EatSSU-iOS +// +// Created by 한금준 on 9/28/25. +// + +import UIKit +import SnapKit + +import EATSSUDesign + +final class MenuLikeCell: UITableViewCell { + + // MARK: - Properties + + static let identifier = "MenuLikeCell" + + var onLikeTapped: (() -> Void)? + var isLiked: Bool = false { + didSet { + updateLikeState() + } + } + + // MARK: - UI Components + + /// 메뉴 이름 레이블 + private let menuLabel: UILabel = { + let label = UILabel() + label.font = .body3 + label.textColor = .black + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + }() + + /// 좋아요 버튼 이미지 + private let likeButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = .gray + button.backgroundColor = .clear + button.isUserInteractionEnabled = false + button.imageView?.contentMode = .scaleAspectFit + return button + }() + + /// 좋아요 버튼 컨테이너 + private let likeContainer: UIView = { + let view = UIView() + view.layer.cornerRadius = 14 + view.layer.borderWidth = 1 + view.layer.borderColor = EATSSUDesignAsset.Color.GrayScale.gray300.color.cgColor + view.setContentHuggingPriority(.required, for: .horizontal) + view.setContentCompressionResistancePriority(.required, for: .horizontal) + return view + }() + + /// 스택뷰 (메뉴 레이블 + 좋아요 컨테이너) + private lazy var hStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [menuLabel, likeContainer]) + stack.axis = .horizontal + stack.spacing = 12 + stack.alignment = .center + stack.distribution = .fill + return stack + }() + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setLayout() + setupGesture() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configuration + + private func setupUI() { + selectionStyle = .none + + contentView.addSubview(hStack) + likeContainer.addSubview(likeButton) + } + + private func setLayout() { + hStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview().inset(12).priority(.high) + $0.horizontalEdges.equalToSuperview() + } + + likeContainer.snp.makeConstraints { + $0.height.equalTo(28).priority(.high) + $0.width.equalTo(58).priority(.required) + } + + likeButton.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(CGSize(width: 18, height: 18)) + } + } + + private func setupGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(likeTapped)) + likeContainer.isUserInteractionEnabled = true + likeContainer.addGestureRecognizer(tapGesture) + } + + // MARK: - Actions + + /// 좋아요 버튼 탭 처리 + @objc private func likeTapped() { + onLikeTapped?() + } + + // MARK: - Public Methods + + /// 셀 데이터 바인딩 + /// - Parameters: + /// - menu: 메뉴 이름 + /// - isLiked: 좋아요 상태 + func dataBind(menu: String, isLiked: Bool) { + menuLabel.text = menu + self.isLiked = isLiked + } + + // MARK: - Private Methods + + /// 좋아요 상태에 따른 UI 업데이트 + private func updateLikeState() { + + let image = isLiked + ? EATSSUDesignAsset.Images.thumbUp.image + : EATSSUDesignAsset.Images.thumbUpGray.image + + DispatchQueue.main.async { + let resizedImage = image.withRenderingMode(.alwaysOriginal) + self.likeButton.setImage(resizedImage, for: .normal) + + if self.isLiked { + self.likeContainer.backgroundColor = EATSSUDesignAsset.Color.Main.secondary.color + self.likeContainer.layer.borderColor = EATSSUDesignAsset.Color.Main.primary.color.cgColor + } else { + self.likeContainer.backgroundColor = .clear + self.likeContainer.layer.borderColor = EATSSUDesignAsset.Color.GrayScale.gray300.color.cgColor + } + } + } +} diff --git a/EATSSU/App/Sources/Presentation/Review/View/RateReview/RateView.swift b/EATSSU/App/Sources/Presentation/Review/View/RateReview/RateView.swift index 011a7a02..08b1de65 100644 --- a/EATSSU/App/Sources/Presentation/Review/View/RateReview/RateView.swift +++ b/EATSSU/App/Sources/Presentation/Review/View/RateReview/RateView.swift @@ -2,26 +2,28 @@ // RateView.swift // EatSSU-iOS // -// Created by 박윤빈 on 2023/03/24. +// Created by 한금준 on 2025/09/28. // import UIKit - import SnapKit import EATSSUDesign -final class RateView: BaseUIView { +final class RateView: BaseUIView { + // MARK: - Properties var buttons: [UIButton] = [] var currentStar: Int = 0 - var starNumber: Int = 5 { - didSet { bind() } /// 초기화할 별의 개수 (button의 개수) - } - - // MARK: - UI Component - + private var starNumber: Int = 5 // 내부 프로퍼티로 변경 + + private lazy var starFillImage: UIImage? = EATSSUDesignAsset.Images.icStarYellow.image + private lazy var starEmptyImage: UIImage? = EATSSUDesignAsset.Images.icStarGray.image + + // MARK: - UI Components + + /// 별들을 가로로 배치하는 스택뷰 lazy var starStackView: UIStackView = { let view = UIStackView() view.axis = .horizontal @@ -29,64 +31,82 @@ final class RateView: BaseUIView { view.backgroundColor = .white return view }() - - lazy var starFillImage: UIImage? = EATSSUDesignAsset.Images.icStarYellow.image - - lazy var starEmptyImage: UIImage? = EATSSUDesignAsset.Images.icStarGray.image - + + // MARK: - Initialization + override init(frame: CGRect) { super.init(frame: frame) - bind() + setupStars() + configureUI() + setLayout() } - + @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - UI Configuration - // MARK: - Functions - - override func configureUI() { + internal override func configureUI() { addSubview(starStackView) } - override func setLayout() { + internal override func setLayout() { starStackView.snp.makeConstraints { make in - make.top.leading.bottom.trailing.equalToSuperview() + make.edges.equalToSuperview() } } + + // MARK: - Private Methods + + private func setupStars() { + buttons.forEach { $0.removeFromSuperview() } + buttons.removeAll() - /// 별점 버튼 초기화. tag 생성이 핵심 - func bind() { - for i in 0 ..< 5 { + for i in 0.. Bool { + let currentText = textView.text ?? "" + guard let stringRange = Range(range, in: currentText) else { return false } + let newLength = currentText.count + text.count - range.length + + if newLength > 300 { return false } + + let textToDisplay = currentText.replacingCharacters(in: stringRange, with: text) + characterCountLabel.text = "\(textToDisplay.count) / 300" + return true + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == EATSSUDesignAsset.Color.GrayScale.gray400.color { + textView.text = "" + textView.textColor = .black + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + textView.text = "리뷰 신고 사유를 작성해 주세요" + textView.textColor = EATSSUDesignAsset.Color.GrayScale.gray400.color + characterCountLabel.text = "0 / 300" + } else { + characterCountLabel.text = "\(textView.text.count) / 300" } } } diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/RateNumberView.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/RateNumberView.swift index a7757a25..4584b4b6 100644 --- a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/RateNumberView.swift +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/RateNumberView.swift @@ -2,61 +2,97 @@ // RateNumberView.swift // EatSSU-iOS // -// Created by 박윤빈 on 2023/06/29. +// Created by 한금준 on 2025/10/04. // import UIKit - import SnapKit import EATSSUDesign final class RateNumberView: BaseUIView { + + // MARK: - Properties + + /// 채워진 별 이미지 + var filledStarImage: UIImage? = EATSSUDesignAsset.Images.icStarYellow.image + + /// 빈 별 이미지 + var emptyStarImage: UIImage? = EATSSUDesignAsset.Images.icStarGray.image + // MARK: - UI Components - - let starImageView = UIImageView() - lazy var rateNumberLabel = UILabel() - private lazy var rateNumberStackView = UIStackView(arrangedSubviews: [starImageView,rateNumberLabel]) - - // MARK: - init - + + /// 별 이미지뷰 배열 (5개) + private var starImageViews: [UIImageView] = [] + + /// 별들을 가로로 배치하는 스택뷰 + private lazy var starsStackView = UIStackView() + + /// 전체 레이팅 컴포넌트를 담는 스택뷰 + private lazy var rateNumberStackView = UIStackView(arrangedSubviews: [starsStackView]) + + // MARK: - Initialization + override init(frame: CGRect) { super.init(frame: frame) } - + @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + // MARK: - Layout + override func layoutSubviews() { super.layoutSubviews() } - - // MARK: - Functions + + // MARK: - UI Configuration override func configureUI() { addSubviews(rateNumberStackView) - - starImageView.image = EATSSUDesignAsset.Images.icStarYellow.image - - rateNumberLabel.text = "5" - rateNumberLabel.font = EATSSUDesignFontFamily.Pretendard.medium.font(size: 14) - rateNumberLabel.textColor = .primary + + starImageViews = (0..<5).map { _ in + let imageView = UIImageView() + imageView.image = emptyStarImage + return imageView + } + + starsStackView.axis = .horizontal + starsStackView.spacing = 3 + starsStackView.alignment = .bottom + starImageViews.forEach { starsStackView.addArrangedSubview($0) } rateNumberStackView.axis = .horizontal - rateNumberStackView.spacing = 3 - rateNumberStackView.alignment = .center + rateNumberStackView.spacing = 6 + rateNumberStackView.alignment = .bottom } override func setLayout() { - starImageView.snp.makeConstraints { - $0.height.equalTo(12.adjusted) - $0.width.equalTo(12.adjusted) + starImageViews.forEach { + $0.snp.makeConstraints { + $0.height.equalTo(12.adjusted) + $0.width.equalTo(12.adjusted) + } } rateNumberStackView.snp.makeConstraints { $0.edges.equalToSuperview() } } + + // MARK: - Public Methods + + /// 별점 설정 (1~5점) + /// - Parameter rating: 표시할 별점 (1-5) + func setRating(_ rating: Int) { + for (index, star) in starImageViews.enumerated() { + if index < rating { + star.image = filledStarImage + } else { + star.image = emptyStarImage + } + } + } } diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewDividerCell.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewDividerCell.swift new file mode 100644 index 00000000..0d99c5ef --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewDividerCell.swift @@ -0,0 +1,84 @@ +// +// ReviewDividerCell.swift +// EATSSU +// +// Created by 한금준 on 10/4/25. +// + +import UIKit +import SnapKit + +import EATSSUDesign + +final class ReviewDividerCell: UITableViewCell { + + // MARK: - Properties + + static let identifier = "ReviewDividerCell" + + // MARK: - UI Components + + /// 상단 구분선 + private let divider: UIView = { + let divider = UIView() + divider.backgroundColor = .gray100 + return divider + }() + + /// 리뷰 개수 표시 레이블 + private let label: UILabel = { + let label = UILabel() + label.text = "리뷰 15" + label.font = EATSSUDesignFontFamily.Pretendard.bold.font(size: 16) + label.textColor = .black + return label + }() + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UI Configuration + + private func setupUI() { + contentView.addSubview(divider) + contentView.addSubview(label) + } + + private func setLayout() { + divider.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(12) + } + + label.snp.makeConstraints { + $0.top.equalTo(divider.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.bottom.equalToSuperview() + } + } + + // MARK: - Public Methods + + /// 리뷰 개수로 셀 구성 + /// - Parameter reviewCount: 표시할 리뷰 개수 + func configure(reviewCount: Int) { + let text = "리뷰 \(reviewCount)" + let attributed = NSMutableAttributedString(string: text) + let range = (text as NSString).range(of: "\(reviewCount)") + attributed.addAttribute( + .foregroundColor, + value: EATSSUDesignAsset.Color.Main.primary.color, + range: range + ) + label.attributedText = attributed + } +} diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewEmptyViewCell.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewEmptyViewCell.swift index 597b10f4..201925ac 100644 --- a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewEmptyViewCell.swift +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewEmptyViewCell.swift @@ -2,90 +2,109 @@ // ReviewEmptyViewCell.swift // EatSSU-iOS // -// Created by 박윤빈 on 2023/11/26. +// Created by 한금준 on 10/4/25. // import UIKit - import SnapKit import EATSSUDesign final class ReviewEmptyViewCell: UITableViewCell { + // MARK: - Properties - + static let identifier = "ReviewEmptyViewCell" - + // MARK: - UI Components - - private lazy var reviewIconImageView: UIImageView = { + + /// 빈 상태 이미지 + private lazy var noReviewImageView: UIImageView = { let imageView = UIImageView() - imageView.image = EATSSUDesignAsset.Images.reviewIcon.image - imageView.contentMode = .scaleAspectFit + imageView.tintColor = EATSSUDesignAsset.Color.GrayScale.gray600.color return imageView }() - private lazy var mainLabel: UILabel = { + /// 제목 레이블 + private lazy var titleLabel: UILabel = { let label = UILabel() - label.text = "아직 작성된 리뷰가 없어요!" label.font = .subtitle2 - label.textColor = .gray600 + label.text = "아직 작성된 리뷰가 없어요!" + label.textColor = EATSSUDesignAsset.Color.GrayScale.gray600.color label.textAlignment = .center return label }() - private lazy var subLabel: UILabel = { + /// 설명 레이블 + private lazy var descriptionLabel: UILabel = { let label = UILabel() - label.text = "메뉴에 가장 먼저 리뷰를 남겨주세요" + label.text = "메뉴에 가장 먼저 리뷰를 남겨주세요!" label.font = .caption2 - label.textColor = .gray600 + label.textColor = EATSSUDesignAsset.Color.GrayScale.gray600.color label.textAlignment = .center return label }() - private lazy var contentStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [reviewIconImageView, mainLabel, subLabel]) - stackView.axis = .vertical - stackView.spacing = 12 - stackView.alignment = .center - return stackView + /// 컴포넌트들을 세로로 배치하는 스택뷰 + private lazy var stackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [ + noReviewImageView, + titleLabel, + descriptionLabel + ]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 16 + return stack }() - - // MARK: - Functions - + + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - contentView.addSubview(contentStackView) + setupUI() setLayout() } - + @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - UI Configuration + + private func setupUI() { + contentView.addSubview(stackView) + } - func setLayout() { - reviewIconImageView.snp.makeConstraints { - $0.width.height.equalTo(48) + private func setLayout() { + stackView.snp.makeConstraints { + $0.center.equalToSuperview() } - contentStackView.snp.makeConstraints { - $0.center.equalToSuperview() + noReviewImageView.snp.makeConstraints { + $0.size.equalTo(48) } } - + + // MARK: - Public Methods + + /// 토큰 존재 여부에 따라 셀 구성 + /// - Parameter isTokenExist: 로그인 토큰 존재 여부 func configure(isTokenExist: Bool) { if isTokenExist { - mainLabel.text = "아직 작성된 리뷰가 없어요!" - subLabel.text = "메뉴에 가장 먼저 리뷰를 남겨주세요" + noReviewImageView.image = EATSSUDesignAsset.Images.noReview.image + titleLabel.text = "아직 작성된 리뷰가 없어요" + descriptionLabel.text = "메뉴에 가장 먼저 리뷰를 남겨주세요!" } else { - mainLabel.text = "로그인이 필요합니다" - subLabel.text = "로그인 후 리뷰를 확인하세요" + titleLabel.text = "로그인이 필요합니다" + descriptionLabel.text = "로그인 후 리뷰를 확인하세요" } } + /// 마이페이지용 빈 상태 구성 func configureForMyReview() { - mainLabel.text = "아직 작성한 리뷰가 없어요" - subLabel.text = "첫 리뷰를 남겨 주세요!" + titleLabel.text = "아직 작성한 리뷰가 없어요" + descriptionLabel.text = "첫 리뷰를 남겨 주세요!" } } diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewRateViewCell.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewRateViewCell.swift index c85bbb67..49baeec9 100644 --- a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewRateViewCell.swift +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewRateViewCell.swift @@ -1,67 +1,145 @@ // // ReviewRateViewCell.swift -// EATSSU +// EatSSU-iOS // -// Created by 황상환 on 10/22/25. +// Created by 한금준 on 10/4/25. // import UIKit - import SnapKit import EATSSUDesign final class ReviewRateViewCell: UITableViewCell { + // MARK: - Properties static let identifier = "ReviewRateViewCell" + + /// 리뷰 작성 버튼 탭 핸들러 var handler: (() -> Void)? + + /// 전체 평균 별점 var totalRate: Double = 0 - // MARK: - UI Components + // MARK: - UI Components - Menu Section + + /// 메뉴 정보 컨테이너 + private let menuContainer: UIView = { + let view = UIView() + view.backgroundColor = EATSSUDesignAsset.Color.GrayScale.gray100.color + view.layer.cornerRadius = 12 + view.layer.masksToBounds = true + return view + }() - // 메뉴 섹션 - private let menuLabel: UILabel = { + /// 메뉴 이름 레이블 + var menuLabel: UILabel = { let label = UILabel() - label.font = .header2 - label.textColor = .gray700Basic + label.text = "김치볶음밥 & 계란국" + label.font = .body1 + label.textColor = .black label.numberOfLines = 0 label.textAlignment = .center return label }() - // 왼쪽 평점 섹션 컨테이너 - private let leftRatingContainer = UIView() + /// 메뉴 아이콘 + private let menuIcon: UIImageView = { + let imageView = UIImageView() + imageView.image = EATSSUDesignAsset.Images.icRestaurant.image + return imageView + }() + + /// "오늘의 메뉴" 타이틀 레이블 + private let menuTitleLabel: UILabel = { + let label = UILabel() + label.text = "오늘의 메뉴" + label.font = .subtitle2 + label.textColor = .black + return label + }() + + /// 메뉴 타이틀 섹션 스택뷰 + private lazy var menuTitleStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [menuIcon, menuTitleLabel]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 6 + return stack + }() + + // MARK: - UI Components - Rating Section - private let mainRatingView = MainRatingView() + /// 별점 섹션 컨테이너 + private let rateSectionContainer: UIView = { + let view = UIView() + return view + }() - // 오른쪽 차트 섹션 컨테이너 - private let rightChartContainer = UIView() + /// 큰 별 아이콘 + private let bigStarImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = EATSSUDesignAsset.Images.icStarYellow.image + return imageView + }() - private let reviewCountView = ReviewCountView() - private let chartView = RatingChartView() + /// 평균 별점 숫자 레이블 + var rateNumLabel: UILabel = { + let label = UILabel() + label.text = "4.3" + label.font = .rate + label.textColor = .black + return label + }() - // 리뷰 작성 버튼 - private let addReviewButton: UIButton = { - let button = UIButton() - button.setTitle("리뷰 작성하기", for: .normal) - button.setTitleColor(.white, for: .normal) - button.titleLabel?.font = .button2 - button.backgroundColor = .primary - button.layer.cornerRadius = 10 - return button + // MARK: - UI Components - Rating Chart + + /// 별점별 레이블들 + private let fivePointLabel = ReviewRateViewCell.makePointLabel("5점") + private let fourPointLabel = ReviewRateViewCell.makePointLabel("4점") + private let threePointLabel = ReviewRateViewCell.makePointLabel("3점") + private let twoPointLabel = ReviewRateViewCell.makePointLabel("2점") + private let onePointLabel = ReviewRateViewCell.makePointLabel("1점") + + /// 차트 바 컨테이너들 + var oneChartBar: UIView! + var twoChartBar: UIView! + var threeChartBar: UIView! + var fourChartBar: UIView! + var fiveChartBar: UIView! + + /// 차트 바 전경(채워지는 부분)들 + var oneForeground: UIView! + var twoForeground: UIView! + var threeForeground: UIView! + var fourForeground: UIView! + var fiveForeground: UIView! + + /// Y축(별점) 레이블 스택뷰 + lazy var yAxisStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + fivePointLabel, + fourPointLabel, + threePointLabel, + twoPointLabel, + onePointLabel + ]) + stackView.axis = .vertical + stackView.spacing = 0 + stackView.alignment = .trailing + return stackView }() - // 전체 컨텐츠를 담는 스택뷰 - private lazy var contentStackView: UIStackView = { + /// 전체 별점 표시 스택뷰 + lazy var totalRateStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [ - leftRatingContainer, - rightChartContainer + bigStarImageView, + rateNumLabel ]) stackView.axis = .horizontal - stackView.spacing = 40 + stackView.spacing = 8.adjusted stackView.alignment = .center - stackView.distribution = .fillEqually return stackView }() @@ -69,306 +147,231 @@ final class ReviewRateViewCell: UITableViewCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - - setupUI() - setupLayout() - setupActions() + configureUI() + setLayout() } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - // MARK: - Setup + // MARK: - Helper Methods - private func setupUI() { - backgroundColor = .white - selectionStyle = .none + /// 별점 레이블 생성 헬퍼 + /// - Parameter text: 레이블 텍스트 + /// - Returns: 설정된 UILabel + private static func makePointLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .caption2 + label.textColor = .black + return label + } + + /// 차트 바 생성 헬퍼 + /// - Returns: 차트 바 컨테이너와 전경 뷰 튜플 + private func makeChartBar() -> (container: UIView, foreground: UIView) { + let container = UIView() + container.backgroundColor = .gray200 + container.layer.cornerRadius = 2 + container.layer.masksToBounds = true - contentView.addSubview(menuLabel) - contentView.addSubview(contentStackView) - contentView.addSubview(addReviewButton) + let foreground = UIView() + foreground.backgroundColor = EATSSUDesignAsset.Color.Main.primary.color + foreground.layer.cornerRadius = 2 + foreground.layer.masksToBounds = true - leftRatingContainer.addSubview(mainRatingView) + container.addSubview(foreground) + foreground.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.width.equalTo(0) + } - rightChartContainer.addSubview(reviewCountView) - rightChartContainer.addSubview(chartView) + return (container, foreground) } - private func setupLayout() { - menuLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(10) - $0.leading.trailing.equalToSuperview().inset(16) - } + // MARK: - UI Configuration + + func configureUI() { + backgroundColor = .white - contentStackView.snp.makeConstraints { - $0.top.equalTo(menuLabel.snp.bottom).offset(15) - $0.leading.trailing.equalToSuperview().inset(16) - } + // 차트 바들 생성 + let oneBar = makeChartBar() + oneChartBar = oneBar.container + oneForeground = oneBar.foreground + + let twoBar = makeChartBar() + twoChartBar = twoBar.container + twoForeground = twoBar.foreground + + let threeBar = makeChartBar() + threeChartBar = threeBar.container + threeForeground = threeBar.foreground + + let fourBar = makeChartBar() + fourChartBar = fourBar.container + fourForeground = fourBar.foreground - // 왼쪽 컨테이너 내부 레이아웃 (정중앙) - mainRatingView.snp.makeConstraints { - $0.center.equalToSuperview() + let fiveBar = makeChartBar() + fiveChartBar = fiveBar.container + fiveForeground = fiveBar.foreground + + contentView.addSubviews(menuContainer, rateSectionContainer) + menuContainer.addSubviews(menuTitleStackView, menuLabel) + rateSectionContainer.addSubviews( + totalRateStackView, + yAxisStackView, + oneChartBar, + twoChartBar, + threeChartBar, + fourChartBar, + fiveChartBar + ) + } + + func setLayout() { + menuContainer.snp.makeConstraints { make in + make.top.equalTo(contentView.snp.top).offset(0) + make.centerX.equalToSuperview() + make.width.equalTo(320.adjusted) + make.height.greaterThanOrEqualTo(100) } - // 오른쪽 컨테이너 내부 레이아웃 - reviewCountView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview() + menuTitleStackView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.centerX.equalToSuperview() } - chartView.snp.makeConstraints { - $0.top.equalTo(reviewCountView.snp.bottom).offset(8) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalToSuperview() + menuIcon.snp.makeConstraints { make in + make.width.height.equalTo(20) } - addReviewButton.snp.makeConstraints { - $0.top.equalTo(contentStackView.snp.bottom).offset(20) - $0.leading.trailing.equalToSuperview().inset(16) - $0.height.equalTo(48) - $0.bottom.equalToSuperview().offset(-20) + menuLabel.snp.makeConstraints { make in + make.top.equalTo(menuTitleStackView.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(28) + make.bottom.equalToSuperview().inset(16) } - } - - private func setupActions() { - addReviewButton.addTarget( - self, - action: #selector(touchAddReviewButton), - for: .touchUpInside - ) - } - - @objc - private func touchAddReviewButton() { - handler?() - } -} - -// MARK: - Data Binding -extension ReviewRateViewCell { - func dataBind(data: ReviewRateResponse) { - menuLabel.text = data.menuNames.joined(separator: ", ") - - mainRatingView.configure(rating: data.mainRating ?? 0) - reviewCountView.configure(count: data.totalReviewCount) - chartView.configure(with: data.reviewRatingCount, total: data.totalReviewCount) + rateSectionContainer.snp.makeConstraints { make in + make.top.equalTo(menuLabel.snp.bottom).offset(40) + make.centerX.equalToSuperview().offset(16) + make.width.equalToSuperview().inset(16) + } - totalRate = data.mainRating ?? 0 - } - - func fixMenuDataBind(data: FixedReviewRateResponse) { - menuLabel.text = data.menuName + totalRateStackView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().offset(35.5) + make.leading.equalToSuperview().offset(36) + } - mainRatingView.configure(rating: data.mainRating ?? 0) - reviewCountView.configure(count: data.totalReviewCount) - chartView.configure(with: data.reviewRatingCount, total: data.totalReviewCount) + bigStarImageView.snp.makeConstraints { + $0.height.width.equalTo(24.adjusted) + } - totalRate = data.mainRating ?? 0 - } -} - -// MARK: - MainRatingView (큰 별점 표시) + yAxisStackView.snp.makeConstraints { make in + make.leading.equalTo(totalRateStackView.snp.trailing).offset(36) + make.centerY.equalTo(totalRateStackView) + } -private final class MainRatingView: UIView { - private let starImageView: UIImageView = { - let imageView = UIImageView() - imageView.image = EATSSUDesignAsset.Images.icStarYellow.image - imageView.contentMode = .scaleAspectFit - return imageView - }() - - private let ratingLabel: UILabel = { - let label = UILabel() - label.font = .rate - label.textColor = .gray700Basic - return label - }() - - private lazy var stackView: UIStackView = { - let stack = UIStackView(arrangedSubviews: [starImageView, ratingLabel]) - stack.axis = .horizontal - stack.spacing = 8 - stack.alignment = .center - return stack - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - addSubview(stackView) + oneChartBar.snp.makeConstraints { make in + make.centerY.equalTo(onePointLabel) + make.leading.equalTo(onePointLabel.snp.trailing).offset(7) + make.height.equalTo(5) + make.width.equalTo(115) + } - stackView.snp.makeConstraints { - $0.edges.equalToSuperview() + twoChartBar.snp.makeConstraints { make in + make.centerY.equalTo(twoPointLabel) + make.leading.equalTo(twoPointLabel.snp.trailing).offset(7) + make.height.equalTo(5) + make.width.equalTo(115) } - starImageView.snp.makeConstraints { - $0.width.height.equalTo(24) + threeChartBar.snp.makeConstraints { make in + make.centerY.equalTo(threePointLabel) + make.leading.equalTo(threePointLabel.snp.trailing).offset(7) + make.height.equalTo(5) + make.width.equalTo(115) } - } - - func configure(rating: Double) { - ratingLabel.text = String(format: "%.1f", rating) - } -} - -// MARK: - ReviewCountView (총 리뷰 수) - -private final class ReviewCountView: UIView { - private let titleLabel: UILabel = { - let label = UILabel() - label.text = "총 리뷰 수" - label.font = .caption2 - label.textColor = .gray700Basic - return label - }() - - private let countLabel: UILabel = { - let label = UILabel() - label.font = .caption1 - label.textColor = .primary - return label - }() - - private lazy var stackView: UIStackView = { - let stack = UIStackView(arrangedSubviews: [titleLabel, countLabel]) - stack.axis = .horizontal - stack.spacing = 7 - return stack - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - addSubview(stackView) - stackView.snp.makeConstraints { - $0.edges.equalToSuperview() + fourChartBar.snp.makeConstraints { make in + make.centerY.equalTo(fourPointLabel) + make.leading.equalTo(fourPointLabel.snp.trailing).offset(7) + make.height.equalTo(5) + make.width.equalTo(115) + } + + fiveChartBar.snp.makeConstraints { make in + make.centerY.equalTo(fivePointLabel) + make.leading.equalTo(fivePointLabel.snp.trailing).offset(7) + make.height.equalTo(5) + make.width.equalTo(115) + } + + for item in [onePointLabel, twoPointLabel, threePointLabel, fourPointLabel, fivePointLabel] { + item.snp.makeConstraints { + $0.height.equalTo(18.adjusted) + } } } - func configure(count: Int) { - countLabel.text = "\(count)" - } -} - -// MARK: - RatingChartView (별점 분포 차트) - -private final class RatingChartView: UIView { - private let chartBars: [ChartBarView] = (1...5).reversed().map { ChartBarView(rating: $0) } - - private lazy var stackView: UIStackView = { - let stack = UIStackView(arrangedSubviews: chartBars) - stack.axis = .vertical - stack.spacing = 0 - stack.distribution = .fillEqually - return stack - }() + // MARK: - Public Methods - override init(frame: CGRect) { - super.init(frame: frame) - setupView() + /// 식사(Meal) 통계 데이터로 셀 구성 + /// - Parameter data: 식사 통계 응답 데이터 + func configureWithMealStatistics(_ data: ReviewMealStatisticsResponse) { + let menuNames = data.menuList.map { $0.name } + menuLabel.text = menuNames.joined(separator: ", ") + setRating(data.rating ?? 0) + updateRatingChart(with: data.reviewRatingCount, totalCount: data.totalReviewCount) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + /// 메뉴(Menu) 통계 데이터로 셀 구성 + /// - Parameter data: 메뉴 통계 응답 데이터 + func configureWithMenuStatistics(_ data: ReviewMenuStatisticsResponse) { + menuLabel.text = data.menuName + setRating(data.rating ?? 0) + updateRatingChart(with: data.reviewRatingCount, totalCount: data.totalReviewCount) } - private func setupView() { - addSubview(stackView) - - stackView.snp.makeConstraints { - $0.edges.equalToSuperview() - $0.height.equalTo(90) - } - } + // MARK: - Private Methods - func configure(with ratingCount: StarCount, total: Int) { - let counts = [ - ratingCount.fiveStarCount, - ratingCount.fourStarCount, - ratingCount.threeStarCount, - ratingCount.twoStarCount, - ratingCount.oneStarCount - ] + /// 평균 별점 설정 + /// - Parameter rating: 별점 값 (0.0 ~ 5.0) + private func setRating(_ rating: Double) { + totalRate = rating - for (index, bar) in chartBars.enumerated() { - let count = counts[index] - let ratio = total > 0 ? CGFloat(count) / CGFloat(total) : 0 - bar.configure(ratio: ratio) + if rating == 0.0 { + rateNumLabel.text = "-" + } else { + let formattedRating = String(format: "%.1f", rating) + rateNumLabel.text = formattedRating } } -} - -// MARK: - ChartBarView (개별 차트 바) - -private final class ChartBarView: UIView { - private let ratingLabel: UILabel = { - let label = UILabel() - label.font = .caption2 - label.textColor = .gray700Basic - label.setContentHuggingPriority(.required, for: .horizontal) - return label - }() - - private let barView: UIView = { - let view = UIView() - view.backgroundColor = .gray300 - view.layer.cornerRadius = 4 - view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - return view - }() - - private var barWidthConstraint: Constraint? - init(rating: Int) { - super.init(frame: .zero) - ratingLabel.text = "\(rating)점" - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - addSubview(ratingLabel) - addSubview(barView) + /// 별점별 분포 차트 업데이트 + /// - Parameters: + /// - ratingCount: 별점별 개수 데이터 + /// - totalCount: 전체 리뷰 개수 + private func updateRatingChart(with ratingCount: ReviewRatingCount, totalCount: Int) { + let safeTotal = max(totalCount, 1) - ratingLabel.snp.makeConstraints { - $0.leading.equalToSuperview() - $0.centerY.equalToSuperview() - $0.width.equalTo(30) + fiveForeground.snp.updateConstraints { + $0.width.equalTo(126 * ratingCount.fiveStarCount / safeTotal) } - - barView.snp.makeConstraints { - $0.leading.equalTo(ratingLabel.snp.trailing).offset(8) - $0.trailing.lessThanOrEqualToSuperview() - $0.centerY.equalToSuperview() - $0.height.equalTo(10) - self.barWidthConstraint = $0.width.equalTo(0).constraint + fourForeground.snp.updateConstraints { + $0.width.equalTo(126 * ratingCount.fourStarCount / safeTotal) } - } - - func configure(ratio: CGFloat) { - let maxWidth: CGFloat = 120 - let width = maxWidth * ratio - barWidthConstraint?.update(offset: max(0, width)) + threeForeground.snp.updateConstraints { + $0.width.equalTo(126 * ratingCount.threeStarCount / safeTotal) + } + twoForeground.snp.updateConstraints { + $0.width.equalTo(126 * ratingCount.twoStarCount / safeTotal) + } + oneForeground.snp.updateConstraints { + $0.width.equalTo(126 * ratingCount.oneStarCount / safeTotal) + } + + layoutIfNeeded() } } diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTableCell.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTableCell.swift index fa58b028..78720b31 100644 --- a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTableCell.swift +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTableCell.swift @@ -2,138 +2,59 @@ // ReviewTableCell.swift // EatSSU-iOS // -// Created by 박윤빈 on 2023/03/23. +// Created by 한금준 on 20/11/25. // import UIKit - import SnapKit import EATSSUDesign final class ReviewTableCell: UITableViewCell { - // MARK: - Properties - + static let identifier = "ReviewTableCell" + var handler: (() -> Void)? - var reviewId: Int = .init() - var menuName: String = .init() - - // MARK: - UI Components - - lazy var totalRateView = RateNumberView() - lazy var tasteRateView = RateNumberView() - lazy var quantityRateView = RateNumberView() - - private let tasteLabel: UILabel = { - let label = UILabel() - label.text = "맛" - label.font = .body3 - label.textColor = .black - return label - }() - - private let quantityLabel: UILabel = { - let label = UILabel() - label.text = "양" - label.font = .body3 - label.textColor = .black - return label - }() - - private var dateLabel: UILabel = { - let label = UILabel() - label.text = "2023.03.03" - label.font = .caption3 - label.textColor = .gray600 - return label + var reviewId: Int = 0 + var menuName: String = "" + private var tags: [(name: String, isLiked: Bool)] = [] + private var tagCollectionViewHeightConstraint: Constraint? + + // MARK: - UI Components - Profile Section + + private let userProfileImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = EATSSUDesignAsset.Images.profile.image + imageView.contentMode = .scaleAspectFit + return imageView }() - + private var userNameLabel: UILabel = { let label = UILabel() label.text = "hellosoongsil1234" - label.font = .caption3 - label.textColor = .gray600 - return label - }() - - private var menuNameLabel: UILabel = { - let label = UILabel() - label.text = "계란국" label.font = .caption1 label.textColor = .black return label }() - - private let userProfileImageView: UIImageView = { - let imageView = UIImageView() - imageView.image = EATSSUDesignAsset.Images.profile.image - return imageView - }() - - private var sideButton: BaseButton = { - let button = BaseButton() - button.setTitleColor(.gray400, for: .normal) - button.titleLabel?.font = .caption2 - button.configuration?.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 15) - return button - }() - - var reviewTextView: UITextView = { - let textView = UITextView() - textView.textColor = UIColor.black - textView.isEditable = false - textView.isScrollEnabled = false - textView.backgroundColor = .systemBackground - textView.font = .body1 - textView.text = "여기 계란국 맛집임... 김치볶음밥에 계란후라이 없어서 아쉽 다음에 또 먹어야지" - return textView - }() - - var foodImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFit - imageView.isHidden = true - return imageView - }() - - /// 맛 별점 - lazy var tasteStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [tasteLabel, tasteRateView]) - stackView.axis = .horizontal - stackView.spacing = 4.adjusted - stackView.alignment = .center - return stackView - }() - - /// 양 별점 - lazy var quantityStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [quantityLabel, quantityRateView]) - stackView.axis = .horizontal - stackView.spacing = 4.adjusted - stackView.alignment = .center - return stackView - }() - - /// 별점 - lazy var rateStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [totalRateView, tasteStackView, quantityStackView]) + + lazy var totalRateView = RateNumberView() + + lazy var nameMenuStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [userNameLabel]) stackView.axis = .horizontal stackView.spacing = 8.adjusted stackView.alignment = .center return stackView }() - - /// 이름 + 메뉴 - lazy var nameMenuStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [userNameLabel, menuNameLabel]) + + lazy var rateStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [totalRateView]) stackView.axis = .horizontal stackView.spacing = 8.adjusted stackView.alignment = .center return stackView }() - - /// 이름 + 메뉴 + 별점 + lazy var infoStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [nameMenuStackView, rateStackView]) stackView.axis = .vertical @@ -141,8 +62,7 @@ final class ReviewTableCell: UITableViewCell { stackView.alignment = .leading return stackView }() - - /// 프로필 + 이름 + 메뉴 + 별점 + lazy var profileStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [userProfileImageView, infoStackView]) stackView.axis = .horizontal @@ -150,7 +70,25 @@ final class ReviewTableCell: UITableViewCell { stackView.alignment = .center return stackView }() - + + // MARK: - UI Components - Right Section + + private var dateLabel: UILabel = { + let label = UILabel() + label.text = "2023.03.03" + label.font = .caption3 + label.textColor = .gray600 + return label + }() + + private var sideButton: BaseButton = { + let button = BaseButton() + button.setTitleColor(.gray400, for: .normal) + button.titleLabel?.font = .caption2 + button.configuration?.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 15) + return button + }() + lazy var dateReportStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [sideButton, dateLabel]) stackView.axis = .vertical @@ -158,141 +96,340 @@ final class ReviewTableCell: UITableViewCell { stackView.alignment = .trailing return stackView }() - + + // MARK: - UI Components - Content Section + + private lazy var tagCollectionView: UICollectionView = { + let layout = LeftAlignedCollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.estimatedItemSize = CGSize(width: 100, height: 26) + layout.minimumInteritemSpacing = 8 + layout.minimumLineSpacing = 8 + + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .clear + cv.isScrollEnabled = false + cv.register( + ReviewTagCollectionViewCell.self, + forCellWithReuseIdentifier: ReviewTagCollectionViewCell.identifier + ) + cv.dataSource = self + cv.delegate = self + return cv + }() + + var reviewTextView: UITextView = { + let textView = UITextView() + textView.textColor = UIColor.black + textView.isEditable = false + textView.isScrollEnabled = false + textView.backgroundColor = .systemBackground + textView.font = .body1 + textView.text = "여기 계란국 맛집임... 김치볶음밥에 계란후라이 없어서 아쉽 다음에 또 먹어야지" + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + return textView + }() + + var foodImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + imageView.clipsToBounds = true + return imageView + }() + lazy var contentStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [reviewTextView, foodImageView]) + let stackView = UIStackView(arrangedSubviews: [ + tagCollectionView, + reviewTextView, + foodImageView + ]) stackView.axis = .vertical stackView.spacing = 8.adjusted stackView.alignment = .leading return stackView }() - - // MARK: - Functions - + + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - contentView.addSubview(profileStackView) - contentView.addSubview(dateReportStackView) - contentView.addSubview(contentStackView) + setupUI() setLayout() } - + @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + // MARK: - Lifecycle + override func prepareForReuse() { super.prepareForReuse() - + + tags = [] + tagCollectionView.reloadData() sideButton.setTitle("", for: .normal) sideButton.setImage(UIImage(), for: .normal) foodImageView.image = UIImage() foodImageView.isHidden = true + reviewTextView.text = "" + dateLabel.text = "" + userNameLabel.text = "" } - + + // MARK: - UI Configuration + + private func setupUI() { + contentView.addSubview(profileStackView) + contentView.addSubview(dateReportStackView) + contentView.addSubview(contentStackView) + + contentStackView.setCustomSpacing(8, after: reviewTextView) + } + func setLayout() { userProfileImageView.snp.makeConstraints { make in - make.width.height.equalTo(30) + make.width.height.equalTo(30).priority(.high) } - + profileStackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(5) + make.top.equalToSuperview().offset(15).priority(.high) make.leading.equalToSuperview().offset(16) - make.height.equalTo(50) } - + dateReportStackView.snp.makeConstraints { make in make.centerY.equalTo(profileStackView) make.trailing.equalToSuperview().inset(16) } + + sideButton.snp.makeConstraints { + $0.height.equalTo(12.adjusted) + } contentStackView.snp.makeConstraints { make in - make.top.equalTo(profileStackView.snp.bottom) + make.top.equalTo(profileStackView.snp.bottom).priority(.high) make.leading.equalToSuperview().offset(16) - make.bottom.equalToSuperview().offset(-15) + make.bottom.equalToSuperview().offset(-16).priority(.high) make.trailing.equalToSuperview().offset(-16) } - foodImageView.snp.makeConstraints { make in - make.height.width.equalTo(358) + tagCollectionView.snp.makeConstraints { make in + make.top.equalTo(profileStackView.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview() + tagCollectionViewHeightConstraint = make.height.equalTo(26).priority(.medium).constraint } - - sideButton.snp.makeConstraints { - $0.height.equalTo(12.adjusted) + + foodImageView.snp.makeConstraints { make in + make.top.equalTo(reviewTextView.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview() + make.height.equalTo(foodImageView.snp.width).multipliedBy(0.75) } } - + + // MARK: - Actions + @objc func touchedSideButtonEvent() { handler?() } -} - -// MARK: - Data Bind - -extension ReviewTableCell { - func dataBind(response: MenuDataList) { - menuNameLabel.text = response.menu - menuName = response.menu + + // MARK: - Public Methods + + func dataBind(response: ReviewListItem) { + self.layoutIfNeeded() + + menuName = response.menu?.map { $0.name }.joined(separator: " + ") ?? "" + userNameLabel.text = response.writerNickname - totalRateView.rateNumberLabel.text = "\(response.mainRating)" - if response.tasteRating == nil { - tasteStackView.isHidden = true - } else { - tasteStackView.isHidden = false - tasteRateView.rateNumberLabel.text = "\(response.tasteRating ?? 0)" - } + totalRateView.setRating(Int(response.rating)) + dateLabel.text = response.writtenAt + reviewTextView.text = response.content ?? "" + reviewId = response.reviewId + + let fixedWidth = reviewTextView.frame.size.width + let newSize = reviewTextView.sizeThatFits(CGSize(width: fixedWidth, height: .greatestFiniteMagnitude)) + reviewTextView.frame.size.height = newSize.height - if response.amountRating == nil { - quantityStackView.isHidden = true - } else { - quantityStackView.isHidden = false - quantityRateView.rateNumberLabel.text = "\(response.amountRating ?? 0)" - } - dateLabel.text = response.writedAt - reviewTextView.text = response.content - reviewId = response.reviewID - if let firstImageUrl = response.imgURLList.compactMap({ $0 }).first(where: { !$0.isEmpty }) { + if let firstImageUrl = response.imageUrls.first(where: { !$0.isEmpty }) { foodImageView.isHidden = false foodImageView.kfSetImage(url: firstImageUrl) } else { foodImageView.isHidden = true } + sideButton.setImage(EATSSUDesignAsset.Images.icMenu.image, for: .normal) sideButton.addTarget(self, action: #selector(touchedSideButtonEvent), for: .touchUpInside) - } - - func myPageDataBind(response: MyDataList, nickname: String) { - userNameLabel.text = "\(nickname)" - menuNameLabel.text = response.menuName - totalRateView.rateNumberLabel.text = "\(response.mainRating)" - if response.tasteRating == nil { - tasteStackView.isHidden = true + + if let menuTags = response.menu, !menuTags.isEmpty { + tags = menuTags.map { ($0.name, $0.isLike) } } else { - tasteStackView.isHidden = false - tasteRateView.rateNumberLabel.text = "\(response.tasteRating ?? 0)" + tags = [] } - if response.amountRating == nil { - quantityStackView.isHidden = true - } else { - quantityStackView.isHidden = false - quantityRateView.rateNumberLabel.text = "\(response.amountRating ?? 0)" + tagCollectionView.isHidden = tags.isEmpty + tagCollectionView.reloadData() + tagCollectionView.layoutIfNeeded() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let contentHeight = self.tagCollectionView.collectionViewLayout.collectionViewContentSize.height + + if contentHeight > 0 { + self.tagCollectionViewHeightConstraint?.update(offset: contentHeight) + self.tagCollectionViewHeightConstraint?.layoutConstraints.first?.priority = .required + + self.contentView.layoutIfNeeded() + } } - dateLabel.text = response.writeDate + } + + func myPageDataBind(response: MyReviewListItem, nickname: String) { + self.layoutIfNeeded() + + userNameLabel.text = "\(nickname)" + totalRateView.setRating(Int(response.rating ?? 0)) + dateLabel.text = response.writtenAt + reviewTextView.text = response.content - if response.imgURLList.count != 0 { - if response.imgURLList[0] != "" { - foodImageView.isHidden = false - foodImageView.kfSetImage(url: response.imgURLList[0]) - } + + let fixedWidth = reviewTextView.frame.size.width + let newSize = reviewTextView.sizeThatFits(CGSize(width: fixedWidth, height: .greatestFiniteMagnitude)) + reviewTextView.frame.size.height = newSize.height + + let firstImageUrl = response.imageUrls.first(where: { !$0.isEmpty }) + + if let firstImageUrl { + foodImageView.isHidden = false + foodImageView.kfSetImage(url: firstImageUrl) } else { foodImageView.isHidden = true } + sideButton.addTarget(self, action: #selector(touchedSideButtonEvent), for: .touchUpInside) sideButton.setImage(EATSSUDesignAsset.Images.icMenu.image, for: .normal) sideButton.setTitle("", for: .normal) - reviewId = response.reviewID + + reviewId = response.reviewId + + if !response.menuList.isEmpty { + tags = response.menuList.map { ($0.name, $0.isLike) } + tagCollectionView.isHidden = false + tagCollectionView.reloadData() + tagCollectionView.layoutIfNeeded() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let contentHeight = self.tagCollectionView.collectionViewLayout.collectionViewContentSize.height + + if contentHeight > 0 { + self.tagCollectionViewHeightConstraint?.update(offset: contentHeight) + self.tagCollectionViewHeightConstraint?.layoutConstraints.first?.priority = .required + + self.contentView.layoutIfNeeded() + } + } + } else { + tags = [] + tagCollectionView.isHidden = true + } + + self.contentView.layoutIfNeeded() + } +} + +// MARK: - UICollectionViewDataSource + +extension ReviewTableCell: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return tags.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ReviewTagCollectionViewCell.identifier, + for: indexPath + ) as? ReviewTagCollectionViewCell else { + return UICollectionViewCell() + } + + let tag = tags[indexPath.item] + cell.configure(tagName: tag.name, isLiked: tag.isLiked) + return cell + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension ReviewTableCell: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let tag = tags[indexPath.item] + let maxWidth = collectionView.bounds.width - 32 + + return ReviewTagCollectionViewCell.estimatedSize( + for: tag.name, + isLiked: tag.isLiked, + maxWidth: maxWidth + ) + } +} + +// MARK: - LeftAlignedCollectionViewFlowLayout + +class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard let attributes = super.layoutAttributesForElements(in: rect) else { + return nil + } + + var leftMargin = sectionInset.left + var maxY: CGFloat = -1.0 + + let modifiedAttributes = attributes.compactMap { layoutAttribute -> UICollectionViewLayoutAttributes? in + guard layoutAttribute.representedElementCategory == .cell else { + return layoutAttribute + } + + let copiedAttribute = layoutAttribute.copy() as! UICollectionViewLayoutAttributes + + if copiedAttribute.frame.origin.y >= maxY { + leftMargin = sectionInset.left + } + + copiedAttribute.frame.origin.x = leftMargin + + leftMargin = copiedAttribute.frame.maxX + minimumInteritemSpacing + maxY = max(maxY, copiedAttribute.frame.maxY) + + return copiedAttribute + } + + return modifiedAttributes + } + + override var collectionViewContentSize: CGSize { + guard let collectionView = collectionView else { + return super.collectionViewContentSize + } + + let superSize = super.collectionViewContentSize + let minHeight: CGFloat = 26 + sectionInset.top + sectionInset.bottom + let actualHeight = max(superSize.height, minHeight) + + return CGSize(width: collectionView.bounds.width, height: actualHeight) } } diff --git a/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTagCollectionViewCell.swift b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTagCollectionViewCell.swift new file mode 100644 index 00000000..4f30ba03 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Review/View/SeeReview/ReviewTagCollectionViewCell.swift @@ -0,0 +1,149 @@ +// +// ReviewTagCollectionViewCell.swift +// EATSSU +// +// Created by 한금준 on 10/3/25. +// + +import UIKit +import SnapKit + +import EATSSUDesign + +final class ReviewTagCollectionViewCell: UICollectionViewCell { + + // MARK: - Properties + + static let identifier = "ReviewTagCollectionViewCell" + + // MARK: - UI Components + + /// 좋아요 아이콘 + private let iconImageView: UIImageView = { + let iv = UIImageView() + iv.image = EATSSUDesignAsset.Images.thumbUp.image + iv.isHidden = true + iv.contentMode = .scaleAspectFit + return iv + }() + + /// 태그 이름 레이블 + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .caption3 + label.textColor = .primary + label.numberOfLines = 1 + label.lineBreakMode = .byClipping + label.adjustsFontSizeToFitWidth = false + return label + }() + + /// 아이콘과 레이블을 담는 스택뷰 + private let stackView: UIStackView = { + let sv = UIStackView() + sv.axis = .horizontal + sv.spacing = 4 + sv.alignment = .center + sv.distribution = .fill + return sv + }() + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func layoutSubviews() { + super.layoutSubviews() + contentView.layer.cornerRadius = contentView.bounds.height / 2 + } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + setNeedsLayout() + layoutIfNeeded() + + let size = contentView.systemLayoutSizeFitting(layoutAttributes.size) + var newFrame = layoutAttributes.frame + newFrame.size.width = ceil(size.width) + newFrame.size.height = ceil(size.height) + layoutAttributes.frame = newFrame + + return layoutAttributes + } + + // MARK: - UI Configuration + + private func setupViews() { + contentView.backgroundColor = UIColor.secondary + contentView.layer.borderColor = UIColor.primary.cgColor + contentView.layer.borderWidth = 1 + + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + + iconImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + iconImageView.setContentHuggingPriority(.required, for: .horizontal) + + stackView.addArrangedSubview(iconImageView) + stackView.addArrangedSubview(titleLabel) + contentView.addSubview(stackView) + + iconImageView.snp.makeConstraints { make in + make.width.height.equalTo(12) + } + + stackView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(8) + make.trailing.equalToSuperview().inset(8) + make.top.equalToSuperview().offset(5) + make.bottom.equalToSuperview().inset(5) + } + + contentView.snp.makeConstraints { make in + make.height.equalTo(22) + } + } + + // MARK: - Public Methods + + /// 태그 데이터로 셀 구성 + /// - Parameters: + /// - tagName: 태그 이름 + /// - isLiked: 좋아요 여부 + func configure(tagName: String, isLiked: Bool) { + titleLabel.text = tagName + + if isLiked { + iconImageView.isHidden = false + iconImageView.image = EATSSUDesignAsset.Images.thumbUp.image + } else { + iconImageView.isHidden = true + } + + setNeedsLayout() + layoutIfNeeded() + } + + static func estimatedSize(for text: String, isLiked: Bool, maxWidth: CGFloat) -> CGSize { + let label = UILabel() + label.font = .caption3 + label.text = text + label.numberOfLines = 1 + + let labelSize = label.sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude)) + + let iconWidth: CGFloat = isLiked ? 14 : 0 + let totalWidth = labelSize.width + iconWidth + 16 + let height: CGFloat = 22 + + return CGSize(width: ceil(totalWidth), height: height) + } +} diff --git a/EATSSU/App/Sources/Presentation/Review/ViewController/ChoiceMenuViewController.swift b/EATSSU/App/Sources/Presentation/Review/ViewController/ChoiceMenuViewController.swift deleted file mode 100644 index 767018cc..00000000 --- a/EATSSU/App/Sources/Presentation/Review/ViewController/ChoiceMenuViewController.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// ChoiceMenuViewController.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/06/29. -// - -import UIKit - -import SnapKit -import FirebaseAnalytics - -import EATSSUDesign - -final class ChoiceMenuViewController: BaseViewController { - // MARK: - Properties - - var menuNameList: [String] = [] - var menuIDList: [Int] = [] - var isMenuSelected: [Bool] = [] { - didSet { - choiceMenuTabelView.reloadData() - print(isMenuSelected) - } - } - - private lazy var selectedList: [String] = [] - private var selectedIDList: [Int] = [] - - // MARK: - UI Component - - private let enjoyLabel = UILabel() - private let whichFoodLabel = UILabel() - private lazy var choiceMenuTabelView = UITableView(frame: .zero, style: .plain) - private lazy var nextButton = MainButton() - - // MARK: - Life Cycles - - override func viewDidLoad() { - super.viewDidLoad() - setTableViewConfig() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - /// pop한 후, 다시 메뉴를 선택할 경우를 방지하기 위하여 선택한 리스트를 초기화합니다 - selectedList = [] - selectedIDList = [] - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - logScreenView(screenID: FirebaseScreenID.Review.V1.review_v1_2) - } - - // MARK: - Functions - - override func configureUI() { - whichFoodLabel.text = "어떤 음식에 대한 리뷰인가요?" - whichFoodLabel.font = EATSSUDesignFontFamily.Pretendard.bold.font(size: 16) - whichFoodLabel.textColor = .black - - enjoyLabel.text = "식사는 맛있게 하셨나요?" - enjoyLabel.font = EATSSUDesignFontFamily.Pretendard.medium.font(size: 16) - enjoyLabel.textColor = .gray600 - - choiceMenuTabelView.separatorStyle = .none - - nextButton.setTitle("다음 단계로", for: .normal) - - view.addSubviews( - enjoyLabel, - whichFoodLabel, - choiceMenuTabelView, - nextButton - ) - } - - override func setLayout() { - whichFoodLabel.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.topMargin).inset(25) - $0.centerX.equalToSuperview() - } - - enjoyLabel.snp.makeConstraints { - $0.top.equalTo(whichFoodLabel.snp.bottom).offset(15) - $0.centerX.equalToSuperview() - } - - choiceMenuTabelView.snp.makeConstraints { - $0.top.equalTo(enjoyLabel.snp.bottom).offset(40) - $0.leading.trailing.bottom.equalToSuperview() - } - - nextButton.snp.makeConstraints { - $0.horizontalEdges.equalToSuperview().inset(16) - $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(17) - } - } - - override func setButtonEvent() { - nextButton.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) - } - - override func setCustomNavigationBar() { - super.setCustomNavigationBar() - navigationItem.title = "리뷰 남기기" - } - - private func setTableViewConfig() { - choiceMenuTabelView.delegate = self - choiceMenuTabelView.dataSource = self - choiceMenuTabelView.register(ChoiceMenuTableViewCell.self, - forCellReuseIdentifier: ChoiceMenuTableViewCell.identifier) - } - - private func makeList(menuList: [String], selectedList: [Bool]) { - for i in 0 ..< menuList.count { - if selectedList[i] { - self.selectedList.append(menuList[i]) - selectedIDList.append(menuIDList[i]) - } - } - } - - @objc - func nextButtonTapped() { - makeList(menuList: menuNameList, selectedList: isMenuSelected) - if selectedList.count == 0 { - showToast(message: "리뷰를 작성할 메뉴를 선택해주세요!", type: .info) - } else { - let setRateVC = SetRateViewController() - setRateVC.dataBind(list: selectedList, - idList: selectedIDList, - reviewList: nil, - currentPage: 0) - navigationController?.pushViewController(setRateVC, animated: true) - } - } - - func menuDataBind(menuList: [String], idList: [Int]) { - menuNameList = menuList - menuIDList = idList - for _ in 0 ..< menuNameList.count { - isMenuSelected.append(false) - } - } -} - -extension ChoiceMenuViewController: UITableViewDelegate {} - -extension ChoiceMenuViewController: UITableViewDataSource { - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - menuNameList.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: ChoiceMenuTableViewCell.identifier) as? ChoiceMenuTableViewCell else { return UITableViewCell() } - cell.selectionStyle = .none - cell.dataBind(menu: menuNameList[indexPath.row], isTapped: isMenuSelected[indexPath.row]) - cell.handler = { [weak self] in - guard let self else { return } - cell.isChecked.toggle() - isMenuSelected[indexPath.row].toggle() - } - - return cell - } - - func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { - 50 - } -} diff --git a/EATSSU/App/Sources/Presentation/Review/ViewController/ReportViewController.swift b/EATSSU/App/Sources/Presentation/Review/ViewController/ReportViewController.swift index 09e02e8f..41233d18 100644 --- a/EATSSU/App/Sources/Presentation/Review/ViewController/ReportViewController.swift +++ b/EATSSU/App/Sources/Presentation/Review/ViewController/ReportViewController.swift @@ -20,6 +20,8 @@ final class ReportViewController: BaseViewController { private let reportView = ReportView() private let scrollView = UIScrollView() + private let sendToEATSSUButton = ESButton(size: .big, title: "EAT SSU 팀에게 보내기") + // Variable Properties private var isChecked = false private var isReasonSelected = false @@ -27,7 +29,11 @@ final class ReportViewController: BaseViewController { private var buttonArray: [UIButton] = [] private var contentArray: [String?] = [] private var reviewID: Int = .init() - + + override var shouldHideTabBar: Bool { + return true + } + // MARK: - View Life Cycle override func viewWillAppear(_: Bool) { @@ -46,12 +52,11 @@ final class ReportViewController: BaseViewController { configureUI() setLayout() setScrollViewSetting() - setDelegate() addArray() setButtonEvent() setCustomNavigationBar() - reportView.sendToEATSSUButton.isEnabled = false + sendToEATSSUButton.isEnabled = false } override func viewWillDisappear(_: Bool) { @@ -61,17 +66,25 @@ final class ReportViewController: BaseViewController { // MARK: - Methods override func configureUI() { + view.addSubview(sendToEATSSUButton) view.addSubview(scrollView) scrollView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(sendToEATSSUButton.snp.top) + } + + + sendToEATSSUButton.snp.makeConstraints { make in + make.leading.trailing.equalTo(view).inset(24) +// make.height.equalTo(52) + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).inset(24) } + scrollView.addSubview(reportView) reportView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.equalTo(scrollView.frameLayoutGuide) - - make.height.equalTo(800) } } @@ -99,7 +112,8 @@ final class ReportViewController: BaseViewController { reportView.otherReasonButton].forEach { $0.addTarget(self, action: #selector(checkButtonIsTapped(_:)), for: .touchUpInside) } - reportView.sendToEATSSUButton.addTarget(self, action: #selector(sendButtonIsTapped), for: .touchUpInside) + + sendToEATSSUButton.addTarget(self, action: #selector(sendButtonIsTapped), for: .touchUpInside) } func bindData(reviewID: Int) { @@ -169,21 +183,14 @@ final class ReportViewController: BaseViewController { sender.isSelected = true isChecked = true status = sender.tag - canTextViewUsed(status: status) - - reportView.sendToEATSSUButton.isEnabled = true - } - - private func canTextViewUsed(status: Int) { + if status == 5 { - reportView.reviewReportReasonTextView.isEditable = true + reportView.enableTextView() } else { - reportView.reviewReportReasonTextView.isEditable = false + reportView.disableTextView() } - } - - private func setDelegate() { - reportView.reviewReportReasonTextView.delegate = self + + sendToEATSSUButton.isEnabled = true } @objc @@ -257,19 +264,6 @@ final class ReportViewController: BaseViewController { } } -extension ReportViewController: UITextViewDelegate { - func textView(_: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - let newLength = reportView.reviewReportReasonTextView.text.count - range.length + text.count - reportView.characterCountLabel.text = "\(reportView.reviewReportReasonTextView.text.count) / 300" - - if newLength > 300 { - return false - } else { - return true - } - } -} - // MARK: - Server extension ReportViewController { @@ -307,7 +301,8 @@ extension ReportViewController { NetworkService.shared.request( ReviewRouter.report(param: param), - responseType: Bool.self + responseType: Bool.self, + useAuth: true ) { [weak self] result in switch result { case .success: diff --git a/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift b/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift index 673e49a6..254437ed 100644 --- a/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift +++ b/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift @@ -2,30 +2,63 @@ // ReviewViewController.swift // EatSSU-iOS // -// Created by 최지우 on 2023/04/07. +// Created by 한금준 on 20/11/25. // import UIKit - +import SnapKit import FirebaseAnalytics import Moya import EATSSUDesign final class ReviewViewController: BaseViewController { + // MARK: - Properties - - var menuID: Int = .init() + override var shouldHideTabBar: Bool { true } + + private var shouldShowSuccessToast: Bool = false + + // MARK: - Network + + /// 리뷰 API 프로바이더 + let reviewProvider = MoyaProvider(plugins: [ESMoyaLoggingPlugin()]) + + // MARK: - Data Properties + + /// 메뉴 ID (FIXED 타입) 또는 식사 ID (VARIABLE 타입) + var menuID: Int = 0 + + /// 메뉴 타입 ("FIXED" 또는 "VARIABLE") var type = "VARIABLE" + + /// 메뉴 이름 리스트 private var menuNameList: [String] = [] - private var menuIDList: [Int]? = [Int]() + + /// 메뉴 ID 리스트 + private var menuIDList: [Int]? = [] + + /// 메뉴 이름-ID 매핑 딕셔너리 private var menuDictionary: [String: Int] = [:] - private var reviewList = [MenuDataList]() - private var responseData: ReviewRateResponse? - private var fixedResponseData: FixedReviewRateResponse? - private var isDataLoaded = false - - // MARK: - UI Component + + /// 리뷰 목록 데이터 + private var reviewList = [ReviewListItem]() + + /// 식사(Meal) 통계 데이터 + private var mealStatistics: ReviewMealStatisticsResponse? + + /// 메뉴(Menu) 통계 데이터 + private var menuStatistics: ReviewMenuStatisticsResponse? + + /// 전체 리뷰 개수 + private var totalReviewCount: Int = 0 + + /// 리뷰 작성 가능한 메뉴 목록 (VARIABLE 타입) + private var validMenusForReview: [ReviewValidMenu] = [] + + // MARK: - UI Components + + /// 리뷰 목록 테이블뷰 let reviewTableView: UITableView = { let tableView = UITableView() tableView.separatorStyle = .none @@ -33,54 +66,97 @@ final class ReviewViewController: BaseViewController { return tableView }() - private var activityIndicatorView: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .large) - indicator.startAnimating() - indicator.isHidden = true - return indicator + /// 빈 상태 이미지뷰 + private lazy var noReviewImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = EATSSUDesignAsset.Images.noReview.image + imageView.isHidden = true + return imageView + }() + + /// 리뷰 작성 버튼 컨테이너 + private let reviewTabBarContainer: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 0 + view.clipsToBounds = true + return view + }() + + /// 리뷰 작성 버튼 + private let reviewTabBarView: MainButton = { + let button = MainButton() + button.title = "리뷰 작성하기" + return button }() - // MARK: - Life Cycles + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setTableView() setFirebaseTask() - reviewTableView.estimatedRowHeight = 300 - reviewTableView.rowHeight = UITableView.automaticDimension } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - getReviewRate() + + getStatistics() + if type == "VARIABLE" { + getValidMenusForReview() + } getReviewList(type: type, menuId: menuID) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - logScreenView(screenID: FirebaseScreenID.Review.V1.review_v1_1) + if shouldShowSuccessToast { + showToast(message: "리뷰가 성공적으로 등록되었습니다.") + shouldShowSuccessToast = false + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } - // MARK: - Functions + // MARK: - UI Configuration override func configureUI() { reviewTableView.backgroundColor = .white - view.addSubviews(reviewTableView, - activityIndicatorView) + + view.addSubviews( + reviewTableView, + noReviewImageView, + reviewTabBarContainer + ) + reviewTabBarContainer.addSubview(reviewTabBarView) } override func setLayout() { reviewTableView.snp.makeConstraints { make in - make.top.equalToSuperview() + make.top.equalToSuperview().offset(24) make.leading.trailing.equalToSuperview() - make.bottom.equalToSuperview() + make.bottom.equalTo(reviewTabBarContainer.snp.top) } - activityIndicatorView.snp.makeConstraints { make in + noReviewImageView.snp.makeConstraints { make in make.center.equalToSuperview() } + + reviewTabBarContainer.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide) + $0.height.equalTo(80) + } + + reviewTabBarView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(12) + $0.top.equalToSuperview().offset(12) + } } override func setCustomNavigationBar() { @@ -88,60 +164,100 @@ final class ReviewViewController: BaseViewController { navigationItem.title = "리뷰" } - private func setFirebaseTask() { - FirebaseRemoteConfig.shared.fetchRestaurantInfo() + override func setButtonEvent() { + reviewTabBarView.addTarget( + self, + action: #selector(handleAddReviewButtonTap), + for: .touchUpInside + ) } - func setTableView() { - reviewTableView.register(ReviewTableCell.self, forCellReuseIdentifier: ReviewTableCell.identifier) - reviewTableView.register(ReviewRateViewCell.self, forCellReuseIdentifier: ReviewRateViewCell.identifier) - reviewTableView.register(ReviewEmptyViewCell.self, forCellReuseIdentifier: ReviewEmptyViewCell.identifier) + // MARK: - TableView Setup + + private func setTableView() { + reviewTableView.register( + ReviewTableCell.self, + forCellReuseIdentifier: ReviewTableCell.identifier + ) + reviewTableView.register( + ReviewRateViewCell.self, + forCellReuseIdentifier: ReviewRateViewCell.identifier + ) + reviewTableView.register( + ReviewEmptyViewCell.self, + forCellReuseIdentifier: ReviewEmptyViewCell.identifier + ) + reviewTableView.register( + ReviewDividerCell.self, + forCellReuseIdentifier: ReviewDividerCell.identifier + ) reviewTableView.delegate = self reviewTableView.dataSource = self } - - func bindMenuID(id: Int) { - menuID = id + + // MARK: - Actions + + /// 리뷰 작성 버튼 탭 처리 + @objc private func handleAddReviewButtonTap() { + if type == "VARIABLE" { + let reviewVC = SetRateViewController(mealId: menuID) + reviewVC.dataBind( + list: validMenusForReview.map { $0.name }, + idList: validMenusForReview.map { $0.menuId } + ) + navigationController?.pushViewController(reviewVC, animated: true) + + } else { + let reviewVC = SetRateViewController(menuId: menuID) + reviewVC.dataBind( + list: menuNameList, + idList: menuIDList ?? [] + ) + navigationController?.pushViewController(reviewVC, animated: true) + } } - private func showFixOrDeleteAlert(data: MenuDataList) { - let alert = UIAlertController(title: "리뷰 수정 혹은 삭제", - message: "작성하신 리뷰를 수정 또는 삭제하시겠습니까?", - preferredStyle: UIAlertController.Style.actionSheet) - - let fixAction = UIAlertAction(title: "수정하기", - style: .default, - handler: { _ in - let setRateViewController = SetRateViewController() - setRateViewController.dataBindForFix(list: [data.menu], reivewId: data.reviewID) - setRateViewController.settingForReviewFix(data: data) - self.navigationController?.pushViewController(setRateViewController, animated: true) - }) - - let deleteAction = UIAlertAction(title: "삭제하기", - style: .default, - handler: { _ in - self.showCustomDialog( - title: "리뷰 삭제하기", - message: "해당 리뷰를 삭제할까요?", - cancelButtonTitle: "취소하기", - confirmButtonTitle: "삭제하기" - ) { [weak self] in - self?.deleteReview(reviewID: data.reviewID) + /// 테이블 새로고침 + @objc private func refreshTable(refresh: UIRefreshControl) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.getStatistics() + if self.type == "VARIABLE" { + self.getValidMenusForReview() } - }) + self.getReviewList(type: self.type, menuId: self.menuID) + refresh.endRefreshing() + } + } + + // MARK: - Alert Methods + + /// 리뷰 삭제 확인 알림 표시 + /// - Parameter data: 리뷰 데이터 + private func showDeleteAlert(data: ReviewListItem) { + if !data.isWriter { + self.showReportAlert(reviewID: data.reviewId) + return + } - let cancelAction = UIAlertAction(title: "취소하기", - style: .cancel, - handler: nil) + let title = "리뷰 삭제" + let message = "해당 리뷰를 삭제할까요?" + let confirmButtonTitle = "삭제하기" + let cancelButtonTitle = "취소하기" - alert.addAction(fixAction) - alert.addAction(deleteAction) - alert.addAction(cancelAction) - present(alert, animated: true, completion: nil) + self.showCustomDialog( + title: title, + message: message, + cancelButtonTitle: cancelButtonTitle, + confirmButtonTitle: confirmButtonTitle + ) { [weak self] in + guard let self = self else { return } + self.deleteReview(reviewID: data.reviewId) + } } + /// 리뷰 신고 알림 표시 + /// - Parameter reviewID: 신고할 리뷰 ID private func showReportAlert(reviewID: Int) { showCustomDialog( title: "리뷰 신고하기", @@ -154,60 +270,67 @@ final class ReviewViewController: BaseViewController { self?.navigationController?.pushViewController(reportViewController, animated: true) } } - - // MARK: - Action Method - + /// 로그인으로 이동 + private func pushToLoginVC() { + let loginVC = LoginViewController() + navigationController?.pushViewController(loginVC, animated: true) + } + + // MARK: - Public Methods + func setReviewSubmittedSuccessfully() { + shouldShowSuccessToast = true + } + + /// 메뉴 ID 바인딩 + /// - Parameter id: 메뉴 ID + func bindMenuID(id: Int) { + menuID = id + } + + /// 리뷰 작성 버튼 탭 처리 (로그인 체크 포함) func userTapReviewButton() { - // firebase - write_review_v1 이벤트 호출 - ReviewAnalyticsManager.shared.logWriteReviewV1() if RealmService.shared.isAccessTokenPresent() { - activityIndicatorView.isHidden = false - DispatchQueue.global().async { // 백그라운드 스레드에서 작업을 수행 - // 작업 완료 후 UI 업데이트를 메인 스레드에서 수행 + DispatchQueue.global().async { DispatchQueue.main.async { [self] in - // 고정메뉴인지 판별(메뉴 ID List에 nil값 들어옴) - if menuIDList == nil { - let setRateViewController = SetRateViewController() - menuIDList = [menuID] - setRateViewController.dataBind(list: menuNameList, - idList: menuIDList ?? [], - reviewList: nil, - currentPage: 0) - activityIndicatorView.stopAnimating() - navigationController?.pushViewController(setRateViewController, animated: true) + if type == "FIXED" { + let setRateViewController = SetRateViewController(menuId: menuID) + setRateViewController.dataBind( + list: menuNameList, + idList: menuIDList ?? [] + ) + navigationController?.pushViewController( + setRateViewController, + animated: true + ) } else { - // 고정메뉴이고, 메뉴가 1개일때 선택창으로 안가고 바로 작성창으로 가도록 - if menuIDList?.count == 1 { - let setRateViewController = SetRateViewController() - setRateViewController.dataBind(list: menuNameList, - idList: menuIDList ?? [], - reviewList: nil, - currentPage: 0) - activityIndicatorView.stopAnimating() - navigationController?.pushViewController(setRateViewController, animated: true) - } else { - let choiceMenuViewController = ChoiceMenuViewController() - choiceMenuViewController.menuDataBind(menuList: menuNameList, idList: menuIDList ?? []) - activityIndicatorView.stopAnimating() - navigationController?.pushViewController(choiceMenuViewController, animated: true) - } + let setRateViewController = SetRateViewController(mealId: menuID) + setRateViewController.dataBind( + list: validMenusForReview.map { $0.name }, + idList: validMenusForReview.map { $0.menuId } + ) + navigationController?.pushViewController( + setRateViewController, + animated: true + ) } } } } else { - showAlertControllerWithCancel(title: "로그인이 필요한 서비스입니다", message: "로그인 하시겠습니까?", confirmStyle: .default) { + showAlertControllerWithCancel( + title: "로그인이 필요한 서비스입니다", + message: "로그인 하시겠습니까?", + confirmStyle: .default + ) { self.pushToLoginVC() } } } - - private func pushToLoginVC() { - let loginVC = LoginViewController() - navigationController?.pushViewController(loginVC, animated: true) - } - - func makeDictionary() { + + // MARK: - Helper Methods + + /// 메뉴 이름-ID 딕셔너리 생성 + private func makeDictionary() { if menuIDList != [] { for (index, string) in menuNameList.enumerated() { let idValue = menuIDList?[index] @@ -215,173 +338,312 @@ final class ReviewViewController: BaseViewController { } } } + + /// Firebase 작업 설정 + private func setFirebaseTask() { + FirebaseRemoteConfig.shared.fetchRestaurantInfo() + +#if DEBUG +#else + Analytics.logEvent("ReviewViewControllerLoad", parameters: nil) +#endif + } + + /// 작성 후의 새로고침 함수 + func refreshAllData() { + getStatistics() + if type == "VARIABLE" { + getValidMenusForReview() + } + getReviewList(type: type, menuId: menuID) + } } -// MARK: - UITableView Delegate, DataSource +// MARK: - UITableViewDelegate extension ReviewViewController: UITableViewDelegate { + + /// 셀 선택 처리 func tableView(_: UITableView, didSelectRowAt _: IndexPath) { print("cell did touched") } + + /// 섹션 헤더 높이 + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + switch section { + case 0: + return 0 + case 1: + return 6 + case 2: + return 0 + default: + return 0 + } + } + + /// 섹션 헤더 뷰 + func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) -> UIView? { + let spacerView = UIView() + spacerView.backgroundColor = .clear + return spacerView + } + + /// 셀 높이 + func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + switch indexPath.section { + case 0: + return 251.adjusted + case 1: + return UITableView.automaticDimension + case 2: + if reviewList.count == 0 { + return 300.adjusted + } else { + return UITableView.automaticDimension + } + default: + return UITableView.automaticDimension + } + } } +// MARK: - UITableViewDataSource + extension ReviewViewController: UITableViewDataSource { + + /// 섹션 개수 func numberOfSections(in _: UITableView) -> Int { - 2 + return 3 } - + + /// 섹션별 행 개수 func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return 1 case 1: - // 데이터 로딩 전에는 아무것도 표시 안 함 - if !isDataLoaded { - return 0 - } - // 데이터 로딩 완료 후: 리뷰가 없으면 EmptyCell, 있으면 리뷰 개수만큼 - if reviewList.count == 0 { - return 1 - } else { - return reviewList.count - } + return 1 + case 2: + return reviewList.count == 0 ? 1 : reviewList.count // 리뷰 목록 또는 빈 상태 default: return 0 } } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + /// 셀 구성 + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { switch indexPath.section { case 0: - let cell = tableView.dequeueReusableCell(withIdentifier: ReviewRateViewCell.identifier, for: indexPath) as? ReviewRateViewCell ?? ReviewRateViewCell() - cell.selectionStyle = .none - if type == "FIXED" { - cell.fixMenuDataBind(data: fixedResponseData ?? FixedReviewRateResponse(menuName: "", - totalReviewCount: 0, - mainRating: 0, - amountRating: 0, - tasteRating: 0, - reviewRatingCount: StarCount(fiveStarCount: 0, - fourStarCount: 0, - threeStarCount: 0, - twoStarCount: 0, - oneStarCount: 0))) - } else { - cell.dataBind(data: responseData ?? ReviewRateResponse(menuNames: [""], - totalReviewCount: 0, - mainRating: 0, - amountRating: 0, - tasteRating: 0, - reviewRatingCount: StarCount(fiveStarCount: 0, - fourStarCount: 0, - threeStarCount: 0, - twoStarCount: 0, - oneStarCount: 0))) - } - cell.handler = { [weak self] in - guard let self else { return } - userTapReviewButton() - } - cell.reloadInputViews() - return cell - + return configureStatisticsCell(tableView, indexPath: indexPath) + case 1: - if reviewList.count == 0 { - let cell = tableView.dequeueReusableCell(withIdentifier: ReviewEmptyViewCell.identifier, for: indexPath) as? ReviewEmptyViewCell ?? ReviewEmptyViewCell() - if RealmService.shared.getToken() == "" { - cell.configure(isTokenExist: false) - } else { - cell.configure(isTokenExist: true) - } - cell.selectionStyle = .none - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: ReviewTableCell.identifier, for: indexPath) as? ReviewTableCell ?? ReviewTableCell() - - cell.dataBind(response: reviewList[indexPath.row]) - cell.handler = { [weak self] in - guard let self else { return } - - reviewList[indexPath.row].isWriter ? showFixOrDeleteAlert(data: reviewList[indexPath.row]) - : showReportAlert(reviewID: cell.reviewId) - } - cell.selectionStyle = .none - cell.reloadInputViews() - return cell - } - + return configureDividerCell(tableView, indexPath: indexPath) + + case 2: + return configureReviewCell(tableView, indexPath: indexPath) + default: return UITableViewCell() } } - - func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - switch indexPath.section { - case 0: - UITableView.automaticDimension - case 1: - if reviewList.count == 0 { - 300.adjusted + + // MARK: - Cell Configuration Helpers + + /// 통계 셀 구성 + private func configureStatisticsCell( + _ tableView: UITableView, + indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewRateViewCell.identifier, + for: indexPath + ) as? ReviewRateViewCell ?? ReviewRateViewCell() + + cell.selectionStyle = .none + + if type == "FIXED" { + if let statistics = menuStatistics { + cell.configureWithMenuStatistics(statistics) + } + } else { + if let statistics = mealStatistics { + cell.configureWithMealStatistics(statistics) + } + } + + cell.handler = { [weak self] in + guard let self else { return } + self.userTapReviewButton() + } + + cell.reloadInputViews() + return cell + } + + /// 구분선 셀 구성 + private func configureDividerCell( + _ tableView: UITableView, + indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewDividerCell.identifier, + for: indexPath + ) as? ReviewDividerCell ?? ReviewDividerCell() + + cell.configure(reviewCount: totalReviewCount) + cell.selectionStyle = .none + return cell + } + + /// 리뷰 셀 또는 빈 상태 셀 구성 + private func configureReviewCell( + _ tableView: UITableView, + indexPath: IndexPath + ) -> UITableViewCell { + if reviewList.count == 0 { + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewEmptyViewCell.identifier, + for: indexPath + ) as? ReviewEmptyViewCell ?? ReviewEmptyViewCell() + + if RealmService.shared.getToken() == "" { + cell.configure(isTokenExist: false) } else { - UITableView.automaticDimension + cell.configure(isTokenExist: true) } - default: - UITableView.automaticDimension + cell.selectionStyle = .none + return cell + + } else { + let cell = tableView.dequeueReusableCell( + withIdentifier: ReviewTableCell.identifier, + for: indexPath + ) as? ReviewTableCell ?? ReviewTableCell() + + let reviewItem = reviewList[indexPath.row] + cell.dataBind(response: reviewItem) + + cell.handler = { [weak self] in + guard let self else { return } + + reviewList[indexPath.row].isWriter + ? self.showDeleteAlert(data: reviewList[indexPath.row]) + : self.showReportAlert(reviewID: reviewList[indexPath.row].reviewId) + } + + cell.selectionStyle = .none + cell.reloadInputViews() + return cell } } } -// MARK: - Server Setting +// MARK: - Network Methods extension ReviewViewController { - // 상단 메뉴 별점 불러오는 API - func getReviewRate() { + + /// 통계 데이터 조회 + func getStatistics() { if type == "FIXED" { - NetworkService.shared.request( - ReviewRouter.reviewRate(type, menuID), - responseType: FixedReviewRateResponse.self, - useAuth: true - ) { [weak self] result in - guard let self = self else { return } + getFixedMenuStatistics() + } else { + getMealStatistics() + } + } + + /// 고정 메뉴 통계 조회 + private func getFixedMenuStatistics() { + NetworkService.shared.request( + ReviewRouter.getFixedMenuStatistics(menuID), + responseType: ReviewMenuStatisticsResponse.self, + useAuth: false + ) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let data): + self.menuStatistics = data + self.totalReviewCount = data.totalReviewCount + self.menuNameList = [data.menuName] + self.menuIDList = [self.menuID] + self.makeDictionary() + self.reviewTableView.reloadData() - switch result { - case .success(let data): - self.fixedResponseData = data - self.menuNameList = [data.menuName] - self.reviewTableView.reloadData() - self.makeDictionary() - - case .failure(let error): - print("고정 메뉴 평점 조회 실패: \(error.localizedDescription)") - } + case .failure(let error): + print("❌ Fixed Menu Statistics Error: \(error.localizedDescription)") } - } else { - NetworkService.shared.request( - ReviewRouter.reviewRate(type, menuID), - responseType: ReviewRateResponse.self, - useAuth: true - ) { [weak self] result in - guard let self = self else { return } + } + } + + /// 식사 통계 조회 + private func getMealStatistics() { + NetworkService.shared.request( + ReviewRouter.getMealStatistics(menuID), + responseType: ReviewMealStatisticsResponse.self, + useAuth: false + ) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let data): + self.mealStatistics = data + self.totalReviewCount = data.totalReviewCount + self.menuNameList = data.menuList.map { $0.name } + self.menuIDList = data.menuList.map { $0.id } + self.makeDictionary() + self.reviewTableView.reloadData() - switch result { - case .success(let data): - self.responseData = data - self.menuNameList = data.menuNames - self.reviewTableView.reloadData() - self.makeDictionary() - - case .failure(let error): - print("변동 메뉴 평점 조회 실패: \(error.localizedDescription)") - } + case .failure(let error): + print("❌ Meal Statistics Error: \(error.localizedDescription)") + self.reviewTableView.reloadData() } } } - - // 하단 리뷰 리스트 불러오는 API + + /// 리뷰 작성 가능한 메뉴 목록 조회 (VARIABLE 타입) + func getValidMenusForReview() { + NetworkService.shared.request( + ReviewRouter.getValidMenusForReview(menuID), + responseType: ReviewValidMenusResponse.self, + useAuth: true + ) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let data): + self.validMenusForReview = data.menuList + print("✅ Valid Menus for Review: \(data.menuList.map { $0.name })") + + case .failure(let error): + print("❌ Valid Menus Error: \(error.localizedDescription)") + } + } + } + + /// 리뷰 목록 조회 + /// - Parameters: + /// - type: 메뉴 타입 ("FIXED" 또는 "VARIABLE") + /// - menuId: 메뉴/식사 ID func getReviewList(type: String, menuId _: Int) { + if type == "FIXED" { + getFixedMenuReviewList() + } else { + getMealReviewList() + } + } + + /// 고정 메뉴 리뷰 목록 조회 + private func getFixedMenuReviewList() { NetworkService.shared.request( - ReviewRouter.reviewList(type, menuID), - responseType: ReviewListResponse.self, + ReviewRouter.newReviewList(type, menuID, lastReviewId: nil, page: 0, size: 20), + responseType: NewReviewListResponse.self, useAuth: true ) { [weak self] result in guard let self = self else { return } @@ -389,17 +651,39 @@ extension ReviewViewController { switch result { case .success(let data): self.reviewList = data.dataList - self.isDataLoaded = true self.reviewTableView.reloadData() + print("✅ Fixed Menu Reviews loaded: \(self.reviewList.count) items") case .failure(let error): - print("리뷰 리스트 조회 실패: \(error.localizedDescription)") - self.isDataLoaded = true + print("❌ Fixed Menu Review List Error: \(error.localizedDescription)") + } + } + } + + /// 식사 리뷰 목록 조회 + private func getMealReviewList() { + NetworkService.shared.request( + ReviewRouter.newReviewList(type, menuID, lastReviewId: nil, page: nil, size: 20), + responseType: NewReviewListResponse.self, + useAuth: true + ) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let data): + self.reviewList = data.dataList self.reviewTableView.reloadData() + print("✅ Meal Reviews loaded: \(self.reviewList.count) items") + + case .failure(let error): + print("❌ Meal Review List Error: \(error.localizedDescription)") } } } + + /// 리뷰 삭제 + /// - Parameter reviewID: 삭제할 리뷰 ID func deleteReview(reviewID: Int) { NetworkService.shared.request( ReviewRouter.deleteReview(reviewID), @@ -410,27 +694,35 @@ extension ReviewViewController { switch result { case .success: - self.getReviewRate() - self.updateViewConstraints() - self.getReviewList(type: self.type, menuId: self.menuID) - self.showToast(message: "삭제되었어요 !") + print("✅ Review 삭제 성공") - // 네비게이션 스택에서 HomeViewController 찾아서 새로고침 - if let homeVC = navigationController?.viewControllers.first as? HomeViewController { - homeVC.refreshAfterReview() + self.getStatistics() + if self.type == "VARIABLE" { + self.getValidMenusForReview() } - case .failure(let error): - print("리뷰 삭제 실패: \(error.localizedDescription)") + self.getReviewList(type: self.type, menuId: self.menuID) + self.showToast(message: "리뷰가 성공적으로 삭제되었습니다.") + + case let .failure(error): + print("❌ Delete Review Error: \(error.localizedDescription)") + self.showToast(message: "리뷰 삭제에 실패했습니다.") } } } } +// MARK: - ReviewMenuTypeInfoDelegate + extension ReviewViewController: ReviewMenuTypeInfoDelegate { + + /// 메뉴 타입 정보 델리게이트 func didDelegateReviewMenuTypeInfo(for menuTypeData: ReviewMenuTypeInfo) { - let reviewMenuTypeInfo = ReviewMenuTypeInfo(menuType: menuTypeData.menuType, - menuID: menuTypeData.menuID, - changeMenuIDList: menuTypeData.changeMenuIDList) + let reviewMenuTypeInfo = ReviewMenuTypeInfo( + menuType: menuTypeData.menuType, + menuID: menuTypeData.menuID, + changeMenuIDList: menuTypeData.changeMenuIDList + ) + type = reviewMenuTypeInfo.menuType menuID = reviewMenuTypeInfo.menuID menuIDList = reviewMenuTypeInfo.changeMenuIDList diff --git a/EATSSU/App/Sources/Presentation/Review/ViewController/SetRateViewController.swift b/EATSSU/App/Sources/Presentation/Review/ViewController/SetRateViewController.swift index 9cef6217..264dcf82 100644 --- a/EATSSU/App/Sources/Presentation/Review/ViewController/SetRateViewController.swift +++ b/EATSSU/App/Sources/Presentation/Review/ViewController/SetRateViewController.swift @@ -2,532 +2,526 @@ // SetRateViewController.swift // EatSSU-iOS // -// Created by 박윤빈 on 2023/03/23. +// Created by 한금준 on 29/11/25. // import UIKit - import SnapKit import Moya -import FirebaseAnalytics import EATSSUDesign -final class SetRateViewController: BaseViewController { +final class SetRateViewController: BaseViewController, UINavigationControllerDelegate { + // MARK: - Properties + override var shouldHideTabBar: Bool { true } - private var currentPage: Int = 0 { - didSet { - menuLabel.text = "\(selectedList[currentPage]) 을/를 추천하시겠어요?" - if currentPage == selectedList.count - 1 { - nextButton.setTitle("리뷰 남기기", for: .normal) - } - } - } + private var reviewType: ReviewType = .variable + private var mealID: Int? + private var menuID: Int? + private var reviewId: Int? - private var userPickedImage: UIImage? - private var reviewList: [(BeforeSelectedImageDTO, UIImage?)] = [] - private var selectedIDList: [Int] = [] + private var validMenuIDList: [Int] = [] private var selectedList: [String] = [] - private var reviewId: Int? + private var likedStates: [Bool] = [] + private var userPickedImage: UIImage? + private var isReviewSubmitted = false + + + enum ReviewType { + case fixed + case variable + } + // MARK: - UI Components - private var rateView = RateView() - private var tasteRateView = RateView() - private var quantityRateView = RateView() + private let setRateView = SetRateView() private let imagePickerController = UIImagePickerController() - - private var contentView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - return scrollView - }() - - private let progressView: UIView = { - let view = UIView() - view.backgroundColor = .primary - return view - }() - - private var menuLabel: UILabel = { - let label = UILabel() - label.text = "김치볶음밥 & 계란국을 추천하시겠어요?" - label.font = .subtitle1 - label.textColor = .black - return label - }() - - private var detailLabel: UILabel = { - let label = UILabel() - label.text = "해당 메뉴에 대한 상세한 평가를 남겨주세요." - label.font = .body3 - label.textColor = .gray600 - return label - }() - - private var tasteLabel: UILabel = { - let label = UILabel() - label.text = "맛" - label.font = .subtitle1 - label.textColor = .black - return label - }() - - private var quantityLabel: UILabel = { - let label = UILabel() - label.text = "양" - label.font = .subtitle1 - label.textColor = .black - return label - }() - - private lazy var tasteStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 16.adjusted - stackView.alignment = .center - return stackView - }() - - private lazy var quantityStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 16.adjusted - stackView.alignment = .center - return stackView - }() - - private let userReviewTextView: UITextView = { - let textView = UITextView() - textView.font = .body1 - textView.layer.cornerRadius = 10.adjusted - textView.backgroundColor = .gray100 - textView.layer.borderWidth = 1.adjusted - textView.layer.borderColor = UIColor.gray300.cgColor - textView.textContainerInset = UIEdgeInsets(top: 16.0.adjusted, left: 16.0.adjusted, bottom: 16.0.adjusted, right: 16.0.adjusted) - textView.text = "3글자 이상 작성해주세요!" - textView.textColor = .gray500 - return textView - }() - - private lazy var userReviewImageView: UIImageView = { - let imageView = UIImageView() - imageView.layer.cornerRadius = 10 - imageView.clipsToBounds = true - imageView.isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTappedimageView)) - imageView.addGestureRecognizer(tapGesture) - return imageView - }() - - private lazy var imageContainer: UIView = { - let view = UIView() - view.addSubview(selectImageButton) - view.addSubview(imageCountLabel) - return view - }() - - private lazy var selectImageButton: UIButton = { - let button = UIButton() - var config = UIButton.Configuration.plain() - config.image = EATSSUDesignAsset.Images.addImageButton.image - config.contentInsets = NSDirectionalEdgeInsets(top: -5, leading: 0, bottom: 5, trailing: 0) - button.configuration = config - button.addTarget(self, action: #selector(didSelectedImage), for: .touchUpInside) - button.layer.borderWidth = 1 - button.layer.borderColor = UIColor.gray500.cgColor - button.layer.cornerRadius = 8 - button.clipsToBounds = true - button.contentVerticalAlignment = .center - button.contentHorizontalAlignment = .center - return button - }() - - private let imageCountLabel: UILabel = { - let label = UILabel() - label.text = "사진 0/1" - label.font = .caption3 - label.textColor = .gray500 - label.textAlignment = .center - return label - }() - - private let deleteMethodLabel: UILabel = { - let label = UILabel() - label.text = "사진 클릭 시, 삭제됩니다" - label.font = .caption3 - label.textColor = .gray500 - return label - }() - - private let maximumWordLabel: UILabel = { - let label = UILabel() - label.text = "0 / 300" - label.font = .caption2 - label.textColor = .gray600 - return label - }() - - private var nextButton: MainButton = { - let button = MainButton() - button.title = "다음 단계로" - return button - }() - + + // MARK: - Initializer + + init() { + super.init(nibName: nil, bundle: nil) + } + + init(mealId: Int) { + super.init(nibName: nil, bundle: nil) + self.mealID = mealId + self.reviewType = .variable + } + + init(menuId: Int) { + super.init(nibName: nil, bundle: nil) + self.menuID = menuId + self.reviewType = .fixed + self.validMenuIDList = [menuId] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Life Cycles - + override func viewDidLoad() { super.viewDidLoad() - setDelegate() + + setDelegates() + setupInitialDataFetch() } - + override func viewWillAppear(_: Bool) { addKeyboardNotifications() + if navigationController?.isNavigationBarHidden == true { + navigationController?.isNavigationBarHidden = false + } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Analytics.logEvent(AnalyticsEventScreenView, - parameters: [AnalyticsParameterScreenName: FirebaseScreenID.Review.V1.review_v1_3, - AnalyticsParameterScreenClass: "SetRateViewController"]) - } - - override func viewWillDisappear(_: Bool) { removeKeyboardNotifications() + } - - - // MARK: - Functions - + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + setRateView.menuTableViewHeightConstraint?.update(offset: setRateView.menuTableView.contentSize.height) + } + + // MARK: - Configuration + override func configureUI() { dismissKeyboard() - view.addSubview(scrollView) - scrollView.addSubview(contentView) - contentView.addSubviews(rateView, - menuLabel, - tasteLabel, - quantityLabel, - detailLabel, - tasteStackView, - quantityStackView, - userReviewTextView, - maximumWordLabel, - selectImageButton, - imageCountLabel, - userReviewImageView, - deleteMethodLabel, - nextButton) - - tasteStackView.addArrangedSubviews([tasteLabel, - tasteRateView]) - - quantityStackView.addArrangedSubviews([quantityLabel, - quantityRateView]) + view.addSubview(setRateView) } - + override func setLayout() { - scrollView.snp.makeConstraints { + setRateView.snp.makeConstraints { $0.edges.equalToSuperview() } + } + + override func setButtonEvent() { + setRateView.nextButton.addTarget(self, action: #selector(tappedNextButton), for: .touchUpInside) + setRateView.selectImageButton.addTarget(self, action: #selector(didSelectedImage), for: .touchUpInside) + setRateView.closeButton.addTarget(self, action: #selector(didTappedImageView), for: .touchUpInside) - contentView.snp.makeConstraints { make in - make.top.bottom.equalToSuperview() - make.width.equalTo(scrollView) - } + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTappedImageView)) + setRateView.userReviewImageView.addGestureRecognizer(tapGesture) + } + + override func setCustomNavigationBar() { + super.setCustomNavigationBar() + navigationItem.title = reviewId != nil ? "리뷰 수정하기" : "리뷰 남기기" - menuLabel.snp.makeConstraints { make in - make.top.equalToSuperview().inset(20) - make.centerX.equalToSuperview() - } + navigationItem.hidesBackButton = true + navigationItem.leftBarButtonItem = nil - rateView.snp.makeConstraints { make in - make.top.equalTo(menuLabel.snp.bottom).offset(17) - make.centerX.equalToSuperview() - make.height.equalTo(36.12) - } + let closeImage = EATSSUDesignAsset.Images.icClose.image.withRenderingMode(.alwaysOriginal) - detailLabel.snp.makeConstraints { make in - make.top.equalTo(rateView.snp.bottom).offset(35) - make.centerX.equalToSuperview() - } + let closeUIButton = UIButton(type: .system) + closeUIButton.setImage(closeImage, for: .normal) + closeUIButton.tintColor = .clear + closeUIButton.frame = CGRect(x: 0, y: 0, width: 24, height: 24) + closeUIButton.imageView?.contentMode = .scaleAspectFit - tasteStackView.snp.makeConstraints { make in - make.top.equalTo(detailLabel.snp.bottom).offset(30) - make.centerX.equalToSuperview() + if let imageView = closeUIButton.imageView { + imageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(12) + } } - quantityStackView.snp.makeConstraints { make in - make.top.equalTo(tasteStackView.snp.bottom).offset(30) - make.centerX.equalToSuperview() - } + closeUIButton.addTarget(self, action: #selector(didTapCustomBackButton), for: .touchUpInside) - nextButton.snp.makeConstraints { make in - make.top.equalTo(maximumWordLabel.snp.bottom).offset(132) - make.horizontalEdges.equalToSuperview().inset(16) - make.bottom.equalToSuperview().offset(-15) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: closeUIButton) + } + + // MARK: - Setup & Delegate + + private func setupInitialDataFetch() { + if reviewId == nil { + if reviewType == .variable, let mealId = mealID { + fetchValidMenus(mealId: mealId) + } else if reviewType == .fixed { + likedStates = [false] + setRateView.menuTableView.reloadData() + } } + } - for i in 0 ... 4 { - tasteRateView.buttons[i].snp.makeConstraints { make in - make.height.equalTo(28) - make.width.equalTo(29.3) - } + private func setDelegates() { + setRateView.menuTableView.register(MenuLikeCell.self, forCellReuseIdentifier: MenuLikeCell.identifier) + setRateView.menuTableView.dataSource = self + setRateView.menuTableView.delegate = self + + imagePickerController.delegate = self + imagePickerController.sourceType = .photoLibrary + imagePickerController.allowsEditing = false + setRateView.userReviewTextView.delegate = self + + self.navigationController?.interactivePopGestureRecognizer?.delegate = self + } + + // MARK: - Data Binding - quantityRateView.buttons[i].snp.makeConstraints { make in - make.height.equalTo(28) - make.width.equalTo(29.3) - } + func dataBind(list: [String], idList: [Int]) { + self.selectedList = list + self.validMenuIDList = idList + self.likedStates = Array(repeating: false, count: list.count) + + if idList.count == 1 { + self.reviewType = .fixed + self.menuID = idList.first + } else { + self.reviewType = .variable } + + setRateView.menuTableView.reloadData() + } - userReviewTextView.snp.makeConstraints { make in - make.top.equalTo(quantityStackView.snp.bottom).offset(40) - make.leading.equalToSuperview().offset(16) - make.trailing.equalToSuperview().offset(-16) - make.height.equalTo(181) - } + func dataBindForFix(menuNames: [String], menuIds: [Int], likedStates: [Bool]) { + self.selectedList = menuNames + self.validMenuIDList = menuIds + self.likedStates = likedStates + self.reviewType = .fixed + + setRateView.menuLabel.text = "\(menuNames.first ?? "") 을/를 추천하시겠어요?" + setRateView.menuTableView.reloadData() + view.setNeedsLayout() + } - maximumWordLabel.snp.makeConstraints { make in - make.top.equalTo(userReviewTextView.snp.bottom).offset(7) - make.trailing.equalTo(userReviewTextView) + func dataBindForFix(list: [String], reviewId: Int) { + self.selectedList = list + self.reviewId = reviewId + self.likedStates = Array(repeating: false, count: list.count) + + setRateView.menuLabel.text = "\(list[0]) 을/를 추천하시겠어요?" + setRateView.selectImageButton.isHidden = true + setRateView.deleteMethodLabel.isHidden = true + setRateView.nextButton.setTitle("리뷰 수정 완료하기", for: .normal) + } + + func dataBindForFix( + list: [String], + reviewId: Int, + rating: Int?, + content: String?, + imageUrls: [String], + menuIds: [Int], + likedMenuIds: [Int] + ) { + self.selectedList = list + self.reviewId = reviewId + self.validMenuIDList = menuIds + + if menuIds.count == 1 { + self.reviewType = .fixed + self.menuID = menuIds.first + } else { + self.reviewType = .variable } - - selectImageButton.snp.makeConstraints { - $0.top.equalTo(maximumWordLabel.snp.bottom).offset(15) - $0.leading.equalToSuperview().offset(15) - $0.width.equalTo(60) - $0.height.equalTo(60) + + self.likedStates = menuIds.map { menuId in + likedMenuIds.contains(menuId) } - imageCountLabel.snp.makeConstraints { - $0.top.equalTo(selectImageButton.snp.bottom).offset(-19) - $0.centerX.equalTo(selectImageButton) - $0.width.equalTo(selectImageButton) + setRateView.menuLabel.text = list.count == 1 + ? "\(list[0]) 를/을 추천하시겠어요?" + : "메뉴를 추천하시겠어요?" + + if let rating = rating { + setRateView.rateView.currentStar = rating + setRateView.rateView.settingStarForFix(currentStar: rating) } - userReviewImageView.snp.makeConstraints { - $0.top.equalTo(maximumWordLabel.snp.bottom).offset(15) - $0.leading.equalTo(selectImageButton.snp.trailing).offset(13) - $0.width.equalTo(60) - $0.height.equalTo(60) + if let content = content, !content.isEmpty { + setRateView.userReviewTextView.text = content + setRateView.userReviewTextView.textColor = .black + setRateView.maximumWordLabel.text = "\(content.count) / 300" } - deleteMethodLabel.snp.makeConstraints { - $0.top.equalTo(selectImageButton.snp.bottom).offset(7) - $0.leading.equalTo(selectImageButton) + if let firstImageUrl = imageUrls.first, !firstImageUrl.isEmpty { + setRateView.userReviewImageView.kfSetImage(url: firstImageUrl) + setRateView.updateImageViewState(image: setRateView.userReviewImageView.image, count: 1, isHidden: false) + } else { + setRateView.updateImageViewState(image: nil, count: 0, isHidden: true) } + + setRateView.nextButton.setTitle("완료하기", for: .normal) + setRateView.menuTableView.reloadData() + view.setNeedsLayout() } + + /// 수정할 리뷰의 기존 내용을 화면에 표시 + func settingForReviewFix(data: ReviewListItem) { + // 별점 설정 + setRateView.rateView.currentStar = Int(data.rating) + setRateView.rateView.settingStarForFix(currentStar: Int(data.rating)) + + // 리뷰 텍스트 설정 + setRateView.userReviewTextView.text = data.content ?? "" + setRateView.userReviewTextView.textColor = .black + setRateView.maximumWordLabel.text = "\(data.content?.count ?? 0) / 300" + + if let imageUrl = data.imageUrls.first, !imageUrl.isEmpty { + setRateView.userReviewImageView.kfSetImage(url: imageUrl) + setRateView.updateImageViewState(image: setRateView.userReviewImageView.image, count: 1, isHidden: false) + } else { + setRateView.updateImageViewState(image: nil, count: 0, isHidden: true) + } - override func setButtonEvent() { - nextButton.addTarget(self, action: #selector(tappedNextButton), for: .touchUpInside) + if let menuLikes = data.menu { + self.likedStates = validMenuIDList.map { menuId in + return menuLikes.first(where: { $0.menuId == menuId })?.isLike ?? false + } + } + setRateView.menuTableView.reloadData() } - - override func setCustomNavigationBar() { - super.setCustomNavigationBar() - if reviewId != nil { - navigationItem.title = "리뷰 수정하기" + + // MARK: - Menu Like Logic + + /// 리뷰 좋아요/취소 상태를 토글 + private func toggleLike(for index: Int) { + likedStates[index].toggle() + let idx = IndexPath(row: index, section: 0) + + if let cell = setRateView.menuTableView.cellForRow(at: idx) as? MenuLikeCell { + cell.dataBind(menu: selectedList[index], isLiked: likedStates[index]) } else { - navigationItem.title = "리뷰 남기기" + setRateView.menuTableView.reloadRows(at: [idx], with: .none) } } + + // MARK: - Image Handling Actions - func dataBind(list: [String], idList: [Int], reviewList: [(BeforeSelectedImageDTO, UIImage?)]?, currentPage: Int) { - selectedList = list - selectedIDList = idList - if let reviewList { - self.reviewList = reviewList - } else { - self.reviewList = Array(repeating: (BeforeSelectedImageDTO(mainRating: 0, - amountRating: nil, - tasteRating: nil, - content: ""), - nil), count: idList.count) - } - self.currentPage = currentPage + @objc func didSelectedImage() { + present(imagePickerController, animated: true) } - func dataBindForFix(list: [String], reivewId: Int) { - selectedList = list - reviewId = reivewId - menuLabel.text = "\(selectedList[0]) 을/를 추천하시겠어요?" - selectImageButton.isHidden = true - deleteMethodLabel.isHidden = true - nextButton.setTitle("리뷰 수정 완료하기", for: .normal) + @objc func didTappedImageView() { + userPickedImage = nil + setRateView.updateImageViewState(image: nil, count: 0, isHidden: true) } - - func setDelegate() { - imagePickerController.delegate = self - imagePickerController.sourceType = .photoLibrary - imagePickerController.allowsEditing = false - - userReviewTextView.delegate = self + + // MARK: - Custom Back Button Action + + @objc private func didTapCustomBackButton() { + checkReviewStatusAndConfirmExit { [weak self] shouldPop in + guard let self = self else { return } + + if shouldPop { + self.navigationController?.popViewController(animated: true) + } + } } + + // MARK: - Review Submission Logic @objc func tappedNextButton() { - if userReviewTextView.text == "3글자 이상 작성해주세요!" || userReviewTextView.text.count < 3 { - showToast(message: "리뷰를 3글자 이상 작성해주세요!", type: .info) + guard setRateView.rateView.currentStar != 0 else { + showToast(message: "별점을 입력해주세요!", type: .info) + return + } + + if reviewId != nil { + sendFixReview() } else { - if rateView.currentStar != 0, quantityRateView.currentStar != 0, tasteRateView.currentStar != 0 { - // 리뷰 작성하기 버튼이 isEnabled = true일 때의 area - let param = BeforeSelectedImageDTO(mainRating: rateView.currentStar, - amountRating: quantityRateView.currentStar, - tasteRating: tasteRateView.currentStar, - content: userReviewTextView.text) - - switch reviewId { - case .none: - /// 현재 이미지를 별도 변수에 저장 - let currentImage = userPickedImage - reviewList[currentPage] = (param, currentImage) - - /// 현재 페이지가 마지막 메뉴에 대한 리뷰페이지일 때의 액션 - if currentPage == selectedList.count - 1 { - navigationController?.isNavigationBarHidden = false - sendDataIfCurrentPageIsLast() - } else { - // 다음 리뷰를 위해 현재 화면의 이미지 초기화 - userPickedImage = nil - userReviewImageView.image = nil - imageCountLabel.text = "사진 0/1" - prepareForNextReview() - } - - case let .some(reviewID): - patchFixedReview(reviewId: reviewID, param: param) - } + switch reviewType { + case .variable: + sendMealReview() + case .fixed: + sendMenuReview() + } + } + } + + /// 리뷰 작성/수정 완료 후 이전 화면 + private func moveToReviewVC() { + if let myReviewVC = navigationController?.viewControllers.first(where: { $0 is MyReviewViewController }) as? MyReviewViewController { + navigationController?.popToViewController(myReviewVC, animated: true) + return + } + + if let reviewVC = navigationController?.viewControllers.first(where: { $0 is ReviewViewController }) as? ReviewViewController { + reviewVC.setReviewSubmittedSuccessfully() + navigationController?.popToViewController(reviewVC, animated: true) - } else { - showToast(message: "별점을 모두 입력해주세요!", type: .info) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + reviewVC.refreshAllData() } + + if let homeVC = navigationController?.viewControllers.first as? HomeViewController { + homeVC.refreshAfterReview() + } + return } + + navigationController?.popViewController(animated: true) } +} - private func sendDataIfCurrentPageIsLast() { - _Concurrency.Task { - do { - for (index, review) in reviewList.enumerated() { - let (reviewDTO, image) = review +// MARK: - API Call & Logic + +extension SetRateViewController { + + /// Meal(Variable) 리뷰 작성을 위한 유효 메뉴 목록을 요청 + private func fetchValidMenus(mealId: Int) { + NetworkService.shared.request( + ReviewRouter.getValidMenusForReview(mealId), + responseType: ReviewValidMenusResponse.self, + useAuth: true + ) { [weak self] result in + guard let self = self else { return } + + DispatchQueue.main.async { + switch result { + case .success(let data): + self.selectedList = data.menuList.map { $0.name } + self.validMenuIDList = data.menuList.map { $0.menuId } + self.likedStates = Array(repeating: false, count: self.selectedList.count) - // Firebase 이벤트 로그 - let photoAttached = (image != nil) ? 1 : 0 - let rating = reviewDTO.mainRating - let selection = self.selectedList.count - ReviewAnalyticsManager.shared.logCompleteReviewV1(photoAttached: photoAttached, rating: rating, selection: selection) + self.setRateView.menuTableView.reloadData() + self.view.setNeedsLayout() - // 순차적으로 업로드 - try await uploadReview(reviewDTO: reviewDTO, image: image, menuId: selectedIDList[index]) + case .failure(let error): + print("❌ Error fetching valid menus: \(error)") + self.showToast(message: "메뉴 목록 조회에 실패했습니다.") } + } + } + } + + private func sendFixReview() { + guard let reviewId = reviewId else { + showToast(message: "수정할 리뷰 정보가 없습니다.") + return + } + + _Concurrency.Task { + do { + let menuLikes: [MenuLike] = validMenuIDList.enumerated().map { (index, menuId) in + MenuLike(menuId: menuId, isLike: likedStates[index]) + } + + let request = FixedReviewRequestDTO( + rating: setRateView.rateView.currentStar, + menuLikes: menuLikes, + content: setRateView.userReviewTextView.text + ) + + try await postFixReview(reviewId: reviewId, request: request) await MainActor.run { + self.isReviewSubmitted = true + self.showToast(message: "리뷰가 성공적으로 수정되었습니다.") self.moveToReviewVC() } } catch { await MainActor.run { - print("리뷰 업로드 실패: \(error)") - self.showToast(message: "리뷰 업로드에 실패했습니다.") + print("❌ Review 수정 업로드 실패: \(error)") + self.showToast(message: "리뷰 수정에 실패했습니다.") } } } } - private func uploadReview(reviewDTO: BeforeSelectedImageDTO, image: UIImage?, menuId: Int) async throws { - if let image = image { - // 이미지 업로드 후 리뷰 작성 - let imageUrl = try await uploadImage(image: image) - let request = WriteReviewRequest(content: reviewDTO, imageURL: imageUrl) - try await postReview(request: request, menuId: menuId) - } else { - // 이미지 없이 리뷰만 작성 - let request = WriteReviewRequest(content: reviewDTO, imageURL: "") - try await postReview(request: request, menuId: menuId) + private func sendMealReview() { + guard let mealId = mealID else { + showToast(message: "식단 정보가 없습니다.") + return } - } - private func uploadImage(image: UIImage) async throws -> String { - try await withCheckedThrowingContinuation { continuation in - NetworkService.shared.request( - WriteReviewRouter.uploadImage(image: image), - responseType: UploadImageResponse.self, - useAuth: true - ) { result in - switch result { - case .success(let data): - continuation.resume(returning: data.url) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } + let rawText = setRateView.userReviewTextView.text ?? "" + let trimmedText = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let content = trimmedText.isEmpty || trimmedText == placeholderText ? nil : trimmedText - @objc - func didSelectedImage() { - present(imagePickerController, animated: true, completion: nil) - } + _Concurrency.Task { + do { + var imageUrl: String? + if let image = userPickedImage { + imageUrl = try await uploadImage(image: image) + } + let menuLikes = validMenuIDList.enumerated().map { (index, menuId) in + MenuLike(menuId: menuId, isLike: likedStates[index]) + } - @objc - func didTappedimageView() { - userReviewImageView.image = nil // 이미지 삭제 - userPickedImage = nil - imageCountLabel.text = "사진 0/1" - } + let request = WriteReviewMealRequest( + mealId: mealId, + rating: setRateView.rateView.currentStar, + menuLikes: menuLikes, + content: content, + imageUrls: imageUrl != nil ? [imageUrl!] : [] + ) + try await postMealReview(request: request) - private func prepareForNextReview() { - let setRateVC = SetRateViewController() - setRateVC.dataBind(list: selectedList, - idList: selectedIDList, - reviewList: reviewList, - currentPage: currentPage + 1) - navigationController?.pushViewController(setRateVC, animated: true) - } + await MainActor.run { + self.isReviewSubmitted = true + self.moveToReviewVC() + } - // 리뷰 리스트 보는 화면으로 넘어가도록 하는 함수 - private func moveToReviewVC() { - if let reviewViewController = navigationController?.viewControllers.first(where: { $0 is ReviewViewController }) { - navigationController?.popToViewController(reviewViewController, animated: true) - - // 네비게이션 스택에서 HomeViewController 찾아서 새로고침 - if let homeVC = navigationController?.viewControllers.first as? HomeViewController { - homeVC.refreshAfterReview() + } catch { + await MainActor.run { + print("❌ Meal 리뷰 업로드 실패: \(error)") + self.showToast(message: "리뷰 업로드에 실패했습니다.") + } } } } + + private func sendMenuReview() { + guard let menuId = menuID ?? validMenuIDList.first else { + showToast(message: "메뉴 정보가 없습니다.") + return + } - func settingForReviewFix(data: MenuDataList) { - rateView.currentStar = data.mainRating - rateView.settingStarForFix(currentStar: data.mainRating) + let rawText = setRateView.userReviewTextView.text ?? "" + let trimmedText = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let content = trimmedText.isEmpty || trimmedText == placeholderText ? nil : trimmedText + + _Concurrency.Task { + do { + var imageUrl: String? + if let image = userPickedImage { + imageUrl = try await uploadImage(image: image) + } - quantityRateView.currentStar = data.amountRating ?? 0 - quantityRateView.settingStarForFix(currentStar: data.amountRating ?? 0) + let menuLike = MenuLike( + menuId: menuId, + isLike: likedStates.first ?? false + ) - tasteRateView.currentStar = data.tasteRating ?? 0 - tasteRateView.settingStarForFix(currentStar: data.tasteRating ?? 0) + let request = WriteReviewMenuRequest( + rating: setRateView.rateView.currentStar, + menuLike: menuLike, + content: content, + imageUrls: imageUrl != nil ? [imageUrl!] : [] + ) - userReviewTextView.text = data.content - userReviewTextView.textColor = .black - } -} + try await postMenuReview(request: request) -// MARK: - Server + await MainActor.run { + self.isReviewSubmitted = true + self.moveToReviewVC() + } -extension SetRateViewController { - /// 이미지 O -> URL 받고, URL을 넣어서 리뷰 작성 요청 - /// 이미지 X -> URL 없이 리뷰 작성 요청 - /// 이미지가 아예 없을 때 어떤 경우로 빠지는지 보고, 거기에서 호출하도록 하기 - private func postReview(request: WriteReviewRequest, menuId: Int) async throws { + } catch { + await MainActor.run { + print("❌ Menu 리뷰 업로드 실패: \(error)") + self.showToast(message: "리뷰 업로드에 실패했습니다.") + } + } + } + } + + // MARK: - Network Utility Methods (Private) + + private func postMenuReview(request: WriteReviewMenuRequest) async throws { try await withCheckedThrowingContinuation { continuation in NetworkService.shared.request( - WriteReviewRouter.writeNewReview(param: request, menuID: menuId), + WriteReviewRouter.writeMenuReview(param: request), responseType: Bool.self, useAuth: true ) { result in @@ -541,135 +535,206 @@ extension SetRateViewController { } } - private func patchFixedReview(reviewId: Int, param: BeforeSelectedImageDTO) { - NetworkService.shared.request( - ReviewRouter.fixReview(reviewId, param), - responseType: Bool.self, - useAuth: true - ) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success: - self.navigationController?.popViewController(animated: true) - - case .failure(let error): - print("리뷰 수정 실패: \(error.localizedDescription)") - RealmService.shared.resetDB() - self.navigateToLogin() + private func postMealReview(request: WriteReviewMealRequest) async throws { + try await withCheckedThrowingContinuation { continuation in + NetworkService.shared.request( + WriteReviewRouter.writeMealReview(param: request), + responseType: Bool.self, + useAuth: true + ) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } } } } - private func navigateToLogin() { - let loginVC = LoginViewController() - loginVC.toastMessage = "세션이 만료되었습니다. 다시 로그인해주세요." - loginVC.toastType = .info - - DispatchQueue.main.async { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) { - keyWindow.replaceRootViewController(UINavigationController(rootViewController: loginVC)) + private func uploadImage(image: UIImage) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + NetworkService.shared.request( + WriteReviewRouter.uploadImage(image: image), + responseType: UploadImageResponse.self, + useAuth: true + ) { result in + switch result { + case .success(let data): + continuation.resume(returning: data.url) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func postFixReview(reviewId: Int, request: FixedReviewRequestDTO) async throws { + try await withCheckedThrowingContinuation { continuation in + NetworkService.shared.request( + WriteReviewRouter.fixReview(reviewId: reviewId, param: request), + responseType: Bool.self, + useAuth: true + ) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } } } } } -// MARK: - UIImagePickerControllerDelegate +// MARK: - UITableViewDataSource & Delegate -extension SetRateViewController: UIImagePickerControllerDelegate { - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { - userReviewImageView.image = image - userPickedImage = image - imageCountLabel.text = "사진 1/1" +extension SetRateViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return selectedList.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: MenuLikeCell.identifier, for: indexPath) as? MenuLikeCell else { + return UITableViewCell() + } + + cell.dataBind(menu: selectedList[indexPath.row], isLiked: likedStates[indexPath.row]) + + cell.onLikeTapped = { [weak self] in + guard let self else { return } + self.toggleLike(for: indexPath.row) } - picker.dismiss(animated: true, completion: nil) + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + toggleLike(for: indexPath.row) } } // MARK: - UITextViewDelegate extension SetRateViewController: UITextViewDelegate { - func textView(_: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - let newLength = userReviewTextView.text.count - range.length + text.count - maximumWordLabel.text = "\(userReviewTextView.text.count) / 300" - if newLength > 300 { - return false - } + + private var placeholderText: String { + return "메뉴에 대한 상세한 리뷰를 작성해주세요" + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + let currentText = textView.text ?? "" + guard let stringRange = Range(range, in: currentText) else { return false } + + let finalLength = currentText.count + text.count - range.length + + if finalLength > 300 { return false } + + let textToDisplay = currentText.replacingCharacters(in: stringRange, with: text) + setRateView.maximumWordLabel.text = "\(textToDisplay.count) / 300" + return true } - + func textViewDidBeginEditing(_ textView: UITextView) { - if textView.text == "3글자 이상 작성해주세요!" { + if textView.text == placeholderText { textView.text = "" textView.textColor = .black } } - + func textViewDidEndEditing(_ textView: UITextView) { if textView.text.isEmpty { - textView.text = "3글자 이상 작성해주세요!" - textView.textColor = .gray500 + setRateView.setInitialTextViewState() + } else { + setRateView.maximumWordLabel.text = "\(textView.text.count) / 300" } } } -// MARK: - UINavigationControllerDelegate +// MARK: - ImagePicker & Navigation Delegate -extension SetRateViewController: UINavigationControllerDelegate { - func navigationController(_: UINavigationController, willShow viewController: UIViewController, animated _: Bool) { - if viewController == self { - // Pop 되기 직전의 로직을 여기서 실행 - print("Back button pressed, will pop the current view controller") +extension SetRateViewController: UIImagePickerControllerDelegate, UIGestureRecognizerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + userPickedImage = image + setRateView.updateImageViewState(image: image, count: 1, isHidden: false) + } + picker.dismiss(animated: true) + } + + private func checkReviewStatusAndConfirmExit(completion: @escaping (Bool) -> Void) { + let textHasContent = setRateView.userReviewTextView.text != placeholderText && !(setRateView.userReviewTextView.text ?? "").isEmpty + let isReviewStarted: Bool = setRateView.rateView.currentStar > 0 || textHasContent + + if reviewId == nil, isReviewStarted { + let title = "나가시겠어요?" + let message = "지금 나가면 작성한 내용이 저장되지 않습니다." + let confirmButtonTitle = "나가기" + let cancelButtonTitle = "계속 작성" + + self.showCustomDialog( + title: title, + message: message, + cancelButtonTitle: cancelButtonTitle, + confirmButtonTitle: confirmButtonTitle + ) { + completion(true) + } + } else { + completion(true) } } - // 키보드가 나타났다는 알림을 받으면 실행할 메서드 - @objc - func keyboardWillShow(_ noti: NSNotification) { - // 키보드의 높이만큼 화면을 올려준다. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer == navigationController?.interactivePopGestureRecognizer else { + return true + } + + let textHasContent = setRateView.userReviewTextView.text != placeholderText + && !(setRateView.userReviewTextView.text ?? "").isEmpty + let isReviewStarted = setRateView.rateView.currentStar > 0 || textHasContent + + if reviewId == nil, isReviewStarted { + checkReviewStatusAndConfirmExit { [weak self] shouldPop in + guard let self = self else { return } + if shouldPop { + self.navigationController?.popViewController(animated: true) + } + } + return false + } + return true + } +} + +// MARK: - Keyboard Handling + +extension SetRateViewController { + @objc func keyboardWillShow(_ noti: NSNotification) { if let keyboardFrame: NSValue = noti.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { let keyboardRectangle = keyboardFrame.cgRectValue - UIView.animate( - withDuration: 0.3, - animations: { - self.view.transform = CGAffineTransform(translationX: 0, y: -keyboardRectangle.height) - self.navigationController?.isNavigationBarHidden = true - } - ) + UIView.animate(withDuration: 0.3) { + self.view.transform = CGAffineTransform(translationX: 0, y: -keyboardRectangle.height + self.view.safeAreaInsets.bottom) + self.navigationController?.isNavigationBarHidden = true + } } } - // 키보드가 사라졌다는 알림을 받으면 실행할 메서드 - @objc - func keyboardWillHide(_: NSNotification) { + @objc func keyboardWillHide(_: NSNotification) { view.transform = .identity navigationController?.isNavigationBarHidden = false } - - // 노티피케이션을 추가하는 메서드 + func addKeyboardNotifications() { - // 키보드가 나타날 때 앱에게 알리는 메서드 추가 - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillShow(_:)), - name: UIResponder.keyboardWillShowNotification, - object: nil) - // 키보드가 사라질 때 앱에게 알리는 메서드 추가 - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), - name: UIResponder.keyboardWillHideNotification, - object: nil) - } - - // 노티피케이션을 제거하는 메서드 + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + func removeKeyboardNotifications() { - // 키보드가 나타날 때 앱에게 알리는 메서드 제거 - NotificationCenter.default.removeObserver(self, - name: UIResponder.keyboardWillShowNotification, - object: nil) - // 키보드가 사라질 때 앱에게 알리는 메서드 제거 - NotificationCenter.default.removeObserver(self, - name: UIResponder.keyboardWillHideNotification, - object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) } } diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index d6f979a1..4f78336b 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -133,6 +133,42 @@ final class CustomTabBarContainerController: UITabBarController { } } + + /// 탭바를 숨기거나 표시합니다. + /// - Parameters: + /// - hidden: true면 숨김, false면 표시 + /// - animated: 애니메이션 여부 + public override func setTabBarHidden(_ hidden: Bool, animated: Bool) { + // 이미 원하는 상태라면 종료 + if tabBar.isHidden == hidden, tabBar.alpha == (hidden ? 0 : 1) { + return + } + + let tabBarHeight = tabBar.frame.height + let targetTransform: CGAffineTransform = hidden + ? CGAffineTransform(translationX: 0, y: tabBarHeight) + : .identity + let targetAlpha: CGFloat = hidden ? 0 : 1 + + // 표시로 전환 시에는 먼저 isHidden을 풀어야 애니메이션이 보임 + if !hidden { tabBar.isHidden = false } + + let animations = { + self.tabBar.transform = targetTransform + self.tabBar.alpha = targetAlpha + } + + if animated { + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: animations) { _ in + // 숨김 전환 완료 후 isHidden 처리 + if hidden { self.tabBar.isHidden = true } + } + } else { + animations() + tabBar.isHidden = hidden + } + } + // MARK: - Private Helpers /// 로그인 필요 시 알림창 표시 diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/Contents.json new file mode 100644 index 00000000..026d4e67 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_close.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/ic_close.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/ic_close.png new file mode 100644 index 00000000..58fdaca8 Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_close.imageset/ic_close.png differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/Contents.json new file mode 100644 index 00000000..e01bb10f --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_restaurant.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/ic_restaurant.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/ic_restaurant.png new file mode 100644 index 00000000..fbd9ec7b Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_restaurant.imageset/ic_restaurant.png differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/Contents.json new file mode 100644 index 00000000..648b31d7 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "noReview.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/noReview.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/noReview.png new file mode 100644 index 00000000..eed035e8 Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/noReview.imageset/noReview.png differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/Contents.json new file mode 100644 index 00000000..3de50eac --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thumb-up_gray.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/thumb-up_gray.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/thumb-up_gray.png new file mode 100644 index 00000000..7d56a96a Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/thumb-up_gray.imageset/thumb-up_gray.png differ diff --git a/Gemfile b/Gemfile index 7a118b49..6e67cf24 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ source "https://rubygems.org" gem "fastlane" + +gem "abbrev" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 116d7a80..1ef6847d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ GEM base64 nkf rexml + abbrev (0.1.2) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) @@ -223,6 +224,7 @@ PLATFORMS ruby DEPENDENCIES + abbrev fastlane BUNDLED WITH diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 7d7a937a..3f623e69 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,16 +1,64 @@ -# fastlane/Fastfile default_platform(:ios) platform :ios do - desc "개발 환경 세팅" + desc "개발 환경 인증서 세팅" lane :setup_development do match(type: "development") UI.success("🎉 개발 환경 설정 완료!") end - desc "App Store 배포 환경 세팅" + desc "배포 환경 인증서 세팅" lane :setup_appstore do match(type: "appstore") - UI.success("🚀 App Store 배포 환경 설정 완료!") + UI.success("🚀 배포 환경 설정 완료!") + end + + desc "TestFlight에 앱 배포" + lane :release do |options| + target_type = options[:type] || "dev" + target_version = options[:version] || "3.2.0" + + target_scheme = (target_type == "prod") ? "EATSSU-PROD" : "EATSSU-DEV" + + build_num = latest_testflight_build_number( + api_key_path: "fastlane/api_key.json", + app_identifier: "com.jiwoo.EatSSU" + ) + 1 + + UI.message("📦 배포 타겟: #{target_type} (Scheme: #{target_scheme})") + UI.message("🚀 버전 주입: #{target_version} (Build: #{build_num})") + + sh("cd .. && tuist install") + sh("cd .. && tuist generate --no-open") + + # 인증서 동기화 (Matchfile 및 API Key 연결) + match( + type: "appstore", + readonly: true, + api_key_path: "fastlane/api_key.json" + ) + + gym( + workspace: "EATSSU_WORKSPACE.xcworkspace", + scheme: target_scheme, + export_method: "app-store", + clean: true, + xcargs: "MARKETING_VERSION=#{target_version} CURRENT_PROJECT_VERSION=#{build_num}" + ) + + pilot( + api_key_path: "fastlane/api_key.json", + skip_waiting_for_build_processing: true + ) + + clean_build_artifacts + end + + after_all do |lane| + clean_build_artifacts + end + + error do |lane, exception| + clean_build_artifacts end end \ No newline at end of file diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 00000000..f6c028c5 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,48 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios setup_development + +```sh +[bundle exec] fastlane ios setup_development +``` + +개발 환경 인증서 세팅 + +### ios setup_appstore + +```sh +[bundle exec] fastlane ios setup_appstore +``` + +배포 환경 인증서 세팅 + +### ios release + +```sh +[bundle exec] fastlane ios release +``` + +TestFlight에 앱 배포 + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/report.xml b/fastlane/report.xml new file mode 100644 index 00000000..14c1f3e9 --- /dev/null +++ b/fastlane/report.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +