diff --git a/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/Contents.json b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularEnd.colorset/Contents.json b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularEnd.colorset/Contents.json new file mode 100644 index 00000000..671d55fa --- /dev/null +++ b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularEnd.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x61", + "green" : "0x42", + "red" : "0xEC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularStart.colorset/Contents.json b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularStart.colorset/Contents.json new file mode 100644 index 00000000..9de79167 --- /dev/null +++ b/Qapple/Qapple/Resource/Color/Colors.xcassets/GradientStroke/popularStart.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC2", + "green" : "0xB5", + "red" : "0xFA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Qapple/Qapple/SourceCode/Data/Repository/AnswerCommentRepository.swift b/Qapple/Qapple/SourceCode/Data/Repository/AnswerCommentRepository.swift new file mode 100644 index 00000000..32380987 --- /dev/null +++ b/Qapple/Qapple/SourceCode/Data/Repository/AnswerCommentRepository.swift @@ -0,0 +1,141 @@ +// +// AnswerCommentRepository.swift +// Qapple +// +// Created by 문인범 on 5/20/25. +// + +import ComposableArchitecture +import QappleRepository +import Foundation + + +/** + AnswerComment API 의존성 + */ +struct AnswerCommentRepository { + var fetchAnswerComments: (_ answerId: Int) async throws -> [AnswerComment] + var createAnswerComment: (_ answerId: Int, _ content: String) async throws -> Void + var likeAnswerComment: (_ answerCommentId: Int) async throws -> Void + var deleteAnswerComment: (_ answerCommentId: Int) async throws -> Void +} + + +// MARK: - DependencyKey +extension AnswerCommentRepository: DependencyKey { + static let liveValue: AnswerCommentRepository = Self( + fetchAnswerComments: { answerId in + let response = try await RepositoryService.shared.request { server, accessToken in + try await AnswerCommentAPI.fetchAnswerComments( + answerId: answerId, + server: server, + accessToken: accessToken + ) + } + + let list = response.answerCommentInfos.map { + AnswerComment( + id: $0.answerCommentId, + writeId: $0.writerId, + // TODO: 5/20 문의 필요(writer generation, isLiked, isMine, isReport이 있는지 여부) + writerGeneration: "", + content: $0.content, + heartCount: $0.heartCount, + isLiked: false, + isMine: false, + isReport: false, + createdAt: $0.createdAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), + anonymityId: -2 + ) + } + + return list + }, + createAnswerComment: { answerId, content in + let _ = try await RepositoryService.shared.request { server, accessToken in + try await AnswerCommentAPI.createAnswerComment( + answerId: answerId, + content: content, + server: server, + accessToken: accessToken + ) + } + }, + likeAnswerComment: { answerCommentId in + let _ = try await RepositoryService.shared.request { server, accessToken in + try await AnswerCommentAPI.likeAnswerComment( + answerCommentId: answerCommentId, + server: server, + accessToken: accessToken + ) + } + }, + deleteAnswerComment: { answerCommentId in + let _ = try await RepositoryService.shared.request { server, accessToken in + try await AnswerCommentAPI.deleteAnswerComment( + answerCommentId: answerCommentId, + server: server, + accessToken: accessToken + ) + } + } + ) + + static let previewValue: AnswerCommentRepository = .init( + fetchAnswerComments: { _ in + sampleComments + }, + createAnswerComment: { answerId, content in + print("\(answerId) 게시글에 \"\(content)\" 댓글을 작성했습니다.") + }, + likeAnswerComment: { answerCommentId in + print("\(answerCommentId) 댓글에 좋아요를 눌렀습니다.") + }, + deleteAnswerComment: { answerCommentId in + print("\(answerCommentId) 댓글을 삭제했습니다.") + } + ) + + static let testValue: AnswerCommentRepository = .init( + fetchAnswerComments: { _ in + sampleComments + }, + createAnswerComment: { _, _ in }, + likeAnswerComment: { _ in }, + deleteAnswerComment: { _ in } + ) +} + + +// MARK: DependencyValues +extension DependencyValues { + var answerCommentRepository: AnswerCommentRepository { + get { self[AnswerCommentRepository.self] } + set { self[AnswerCommentRepository.self] = newValue } + } +} + + +// MARK: Test Values +extension AnswerCommentRepository { + private static let sampleComments: [AnswerComment] = { + var result = [AnswerComment]() + for i in 1...10 { + result.append( + AnswerComment( + id: i, + writeId: i, + writerGeneration: "4기", + content: "테스트 댓글\(i)", + heartCount: i, + isLiked: i == 1, + isMine: i == 2, + isReport: i == 3, + createdAt: .now, + anonymityId: -2 + ) + ) + } + return result + }() +} diff --git a/Qapple/Qapple/SourceCode/Data/Repository/AnswerRepository.swift b/Qapple/Qapple/SourceCode/Data/Repository/AnswerRepository.swift index 7381a6ee..1ae9b409 100644 --- a/Qapple/Qapple/SourceCode/Data/Repository/AnswerRepository.swift +++ b/Qapple/Qapple/SourceCode/Data/Repository/AnswerRepository.swift @@ -17,8 +17,10 @@ struct AnswerRepository { QappleAPI.TotalCount, QappleAPI.PaginationInfo ) + var fetchPopularAnswer: (_ question: Question?) async throws -> (Answer?, Question?, Bool) var postAnswer: (_ questionId: Int, _ answer: String) async throws -> Void var deleteAnswer: (_ answerId: Int) async throws -> Void + var likeAnswer: (_ questionId: Int) async throws -> Void } // MARK: - DependencyKey @@ -45,7 +47,10 @@ extension AnswerRepository: DependencyKey { publishedDate: $0.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), isReported: false, isMine: true, - isResignMember: false + isLiked: $0.isLiked, + isResignMember: false, + commentCount: $0.commentCount, + heartCount: $0.heartCount ) } let paginationInfo = QappleAPI.PaginationInfo( @@ -74,7 +79,10 @@ extension AnswerRepository: DependencyKey { publishedDate: $0.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), isReported: $0.isReported, isMine: $0.isMine, - isResignMember: $0.nickname == "알 수 없음" + isLiked: $0.isLiked, + isResignMember: $0.nickname == "알 수 없음", + commentCount: $0.commentCount, + heartCount: $0.heartCount ) } }, @@ -98,7 +106,10 @@ extension AnswerRepository: DependencyKey { publishedDate: $0.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), isReported: $0.isReported, isMine: $0.isMine, - isResignMember: $0.nickname == "알 수 없음" + isLiked: $0.isLiked, + isResignMember: $0.nickname == "알 수 없음", + commentCount: $0.commentCount, + heartCount: $0.heartCount ) } let paginationInfo = QappleAPI.PaginationInfo( @@ -107,6 +118,114 @@ extension AnswerRepository: DependencyKey { ) return (answerList, response.total, paginationInfo) }, + fetchPopularAnswer: { question in + let currentHour = Calendar.current.component(.hour, from: .now) + if currentHour > 12 && currentHour < 19 { + return (nil, nil, false) + } + + let response = try await RepositoryService.shared.request { server, accessToken in + try await QuestionAPI.fetchQuestionList( + threshold: nil, + pageSize: 1, + server: server, + accessToken: accessToken + ) + } + + guard let questionContent = response.content.first else { return (nil, nil, true) } + + let currentQuestion = question ?? Question( + id: questionContent.questionId, + content: questionContent.content, + publishedDate: questionContent.livedAt?.ISO8601ToDate(.yearMonthDateTime) ?? .now, + isAnswered: questionContent.isAnswered, + isLived: questionContent.questionStatus == ("LIVE") + ) + + var popularAnswer: Answer = .init( + id: -1, + writerId: -1, + content: "", + authorNickname: "", + authorGeneration: "", + publishedDate: .init(timeIntervalSince1970: 0), + isReported: false, + isMine: false, + isLiked: false, + isResignMember: false, + commentCount: 0, + heartCount: 0 + ) + + var hasNext = true + var threshold: Int? + + while hasNext { + let answersOfQuestion = try await RepositoryService.shared.request { server, accessToken in + try await AnswerAPI.fetchListOfQuestion( + questionId: Int(currentQuestion.id), + threshold: threshold, + pageSize: 30, + server: server, + accessToken: accessToken + ) + } + + if answersOfQuestion.content.isEmpty { + return (nil, nil, true) + } + + for answer in answersOfQuestion.content { + if answer.isReported { continue } + + let sum = answer.commentCount + answer.heartCount + let popularSum = popularAnswer.heartCount + popularAnswer.commentCount + + if sum > popularSum { + popularAnswer = .init( + id: answer.answerId, + writerId: answer.writerId, + content: answer.content, + authorNickname: answer.nickname, + authorGeneration: answer.writerGeneration, + publishedDate: answer.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), + isReported: false, + isMine: answer.isMine, + isLiked: answer.isLiked, + isResignMember: answer.nickname == "알 수 없음", + commentCount: answer.commentCount, + heartCount: answer.heartCount + ) + } else if sum == popularSum { + let popularAnswerDate = popularAnswer.publishedDate + let answerDate = answer.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds) + + if answerDate > popularAnswerDate { + popularAnswer = .init( + id: answer.answerId, + writerId: answer.writerId, + content: answer.content, + authorNickname: answer.nickname, + authorGeneration: answer.writerGeneration, + publishedDate: answer.writeAt.ISO8601ToDate(.yearMonthDateTimeMilliseconds), + isReported: false, + isMine: answer.isMine, + isLiked: answer.isLiked, + isResignMember: answer.nickname == "알 수 없음", + commentCount: answer.commentCount, + heartCount: answer.heartCount + ) + } + } + } + + hasNext = answersOfQuestion.hasNext + threshold = Int(answersOfQuestion.threshold) + } + + return (popularAnswer, currentQuestion, false) + }, postAnswer: { questionId, answer in let response = try await RepositoryService.shared.request { server, accessToken in try await AnswerAPI.create( @@ -125,6 +244,15 @@ extension AnswerRepository: DependencyKey { accessToken: accessToken ) } + }, + likeAnswer: { answerId in + let response = try await RepositoryService.shared.request { server, accessToken in + try await AnswerAPI.like( + answerId: answerId, + server: server, + accessToken: accessToken + ) + } } ) @@ -140,7 +268,10 @@ extension AnswerRepository: DependencyKey { publishedDate: .init(timeIntervalSinceNow: TimeInterval(i * -5000)), isReported: false, isMine: true, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 0, + heartCount: 0 ) } return (stubProfiles, .init(threshold: "10", hasNext: false)) @@ -151,8 +282,14 @@ extension AnswerRepository: DependencyKey { fetchAnswerListOfQuestion: { _, _ in (stubAnswerList, 25, .init(threshold: "", hasNext: false)) }, + fetchPopularAnswer: { _ in + let question = Question(id: 0, content: "", publishedDate: .now, isAnswered: false, isLived: true) + + return (AnswerRepository.stubAnswerList.first!, question, false) + }, postAnswer: { _, _ in }, - deleteAnswer: { _ in } + deleteAnswer: { _ in }, + likeAnswer: { _ in } ) } @@ -182,7 +319,10 @@ extension AnswerRepository { publishedDate: .init(timeIntervalSinceNow: TimeInterval(i*(-10000))), isReported: i == 2, isMine: i == 1, - isResignMember: i == 3 + isLiked: i == 4, + isResignMember: i == 3, + commentCount: i, + heartCount: i ) ) } diff --git a/Qapple/Qapple/SourceCode/Data/Repository/CommentRepository.swift b/Qapple/Qapple/SourceCode/Data/Repository/BoardCommentRepository.swift similarity index 89% rename from Qapple/Qapple/SourceCode/Data/Repository/CommentRepository.swift rename to Qapple/Qapple/SourceCode/Data/Repository/BoardCommentRepository.swift index a080aa4a..6dc35b80 100644 --- a/Qapple/Qapple/SourceCode/Data/Repository/CommentRepository.swift +++ b/Qapple/Qapple/SourceCode/Data/Repository/BoardCommentRepository.swift @@ -1,5 +1,5 @@ // -// CommentRepository.swift +// BoardCommentRepository.swift // Qapple // // Created by 문인범 on 1/23/25. @@ -13,7 +13,7 @@ import ComposableArchitecture /** Comment API 의존성 */ -struct CommentRepository { +struct BoardCommentRepository { var fetchBoardCommentList: (_ boardId: Int, _ threshold: Int?) async throws -> ([BoardComment], QappleAPI.PaginationInfo) var deleteBoardComment: (_ boardCommentId: Int) async throws -> Void var postBoardComment: (_ boardId: Int, _ content: String) async throws -> Void @@ -22,9 +22,9 @@ struct CommentRepository { // MARK: - DependencyKey -extension CommentRepository: DependencyKey { +extension BoardCommentRepository: DependencyKey { - static let liveValue: CommentRepository = Self( + static let liveValue: BoardCommentRepository = Self( fetchBoardCommentList: { boardId, threshold in let response = try await RepositoryService.shared.request { server, accessToken in try await BoardCommentAPI.fetchList( @@ -85,7 +85,7 @@ extension CommentRepository: DependencyKey { } ) - static let previewValue: CommentRepository = Self( + static let previewValue: BoardCommentRepository = Self( fetchBoardCommentList: { _, _ in (sampleCommentList, .init(threshold: "", hasNext: false)) }, @@ -100,9 +100,9 @@ extension CommentRepository: DependencyKey { } ) - static let testValue: CommentRepository = Self( + static let testValue: BoardCommentRepository = Self( fetchBoardCommentList: { _, _ in - (CommentRepository.sampleCommentList, CommentRepository.samplePaginationInfo) + (BoardCommentRepository.sampleCommentList, BoardCommentRepository.samplePaginationInfo) }, deleteBoardComment: { _ in }, postBoardComment: { _, _ in }, @@ -113,15 +113,15 @@ extension CommentRepository: DependencyKey { // MARK: - DependencyValues extension DependencyValues { - var commentRepository: CommentRepository { - get { self[CommentRepository.self] } - set { self[CommentRepository.self] = newValue } + var boardCommentRepository: BoardCommentRepository { + get { self[BoardCommentRepository.self] } + set { self[BoardCommentRepository.self] = newValue } } } // MARK: - TestValues -extension CommentRepository { +extension BoardCommentRepository { private static let sampleCommentList: [BoardComment] = [ .init( id: 1, diff --git a/Qapple/Qapple/SourceCode/Data/Service/GAService.swift b/Qapple/Qapple/SourceCode/Data/Service/GAService.swift index 80d1de27..cda8038a 100644 --- a/Qapple/Qapple/SourceCode/Data/Service/GAService.swift +++ b/Qapple/Qapple/SourceCode/Data/Service/GAService.swift @@ -26,6 +26,12 @@ enum GAService { /// 게시글 상세 페이지에서 좋아요 case likeBoardFromDetail(board: BulletinBoard) + /// 답변 리스트에서 좋아요 + case likeAnswerFromList(answer: Answer) + + /// 답변 상세 페이지에서 좋아요 + case likeAnswerFromDetail(answer: Answer) + /// 아카데미 일정 확인 case checkAcademySchedule(event: AcademyEventFor4th) @@ -35,6 +41,13 @@ enum GAService { /// 게시글 댓글 좋아요 case likeBoardComment(board: BulletinBoard, boardComment: BoardComment) + /// 답변 댓글 작성 + case postAnswerComment(answer: Answer, comment: String) + + /// 답변 댓글 좋아요 + case likeAnswerComment(answer: Answer, answerComment: AnswerComment) + + /// Push 알림을 눌러 질문 탭으로 이동 case navigateToQuestionTabFromPush(title: String, body: String, questionId: Int) @@ -47,9 +60,13 @@ enum GAService { case .postBoard: "post_board" case .likeBoardFromList: "like_board_from_list" case .likeBoardFromDetail: "like_board_from_detail" + case .likeAnswerFromList: "like_answer_from_list" + case .likeAnswerFromDetail: "like_answer_from_detail" case .checkAcademySchedule: "check_academy_schedule" case .postBoardComment: "post_board_comment" case .likeBoardComment: "like_board_comment" + case .postAnswerComment: "post_answer_comment" + case .likeAnswerComment: "like_answer_comment" case .navigateToQuestionTabFromPush: "navigate_to_question_tab_from_push_notification" case .navigateToBoardCommentFromPush: "navigate_to_board_comment_from_push_notification" } @@ -90,6 +107,20 @@ enum GAService { "heart_count": board.heartCount + 1 ] + case let .likeAnswerFromList(answer): + parameters = [ + "answer_id": answer.id, + "content": answer.content, + "heart_count": answer.heartCount + 1 + ] + + case let.likeAnswerFromDetail(answer): + parameters = [ + "answer_id": answer.id, + "content": answer.content, + "heart_count": answer.heartCount + 1 + ] + case let .checkAcademySchedule(event): parameters = [ "event_title": event.title @@ -111,6 +142,22 @@ enum GAService { "comment_heart_count": comment.heartCount + 1 ] + case let .postAnswerComment(answer, comment): + parameters = [ + "answer_id": answer.id, + "answer_content": answer.content, + "comment_content": comment + ] + + case let .likeAnswerComment(answer, comment): + parameters = [ + "answer_id": answer.id, + "answer_content": answer.content, + "answer_heart_count": answer.heartCount, + "comment_content": comment.content, + "comment_heart_count": comment.heartCount + 1 + ] + case let .navigateToQuestionTabFromPush(title, body, questionId): parameters = [ "question_id": questionId, diff --git a/Qapple/Qapple/SourceCode/Entity/Answer.swift b/Qapple/Qapple/SourceCode/Entity/Answer.swift index 01a8e72d..0cf6fc97 100644 --- a/Qapple/Qapple/SourceCode/Entity/Answer.swift +++ b/Qapple/Qapple/SourceCode/Entity/Answer.swift @@ -33,9 +33,18 @@ struct Answer: Identifiable, Equatable { /// 현재 사용자가 작성한 답변인지 여부 let isMine: Bool + /// 내가 좋아요를 눌렀는지 여부 + var isLiked: Bool + /// 탈퇴한 사용자의 답변인지 여부 let isResignMember: Bool + /// 답변에 대한 댓글 갯수 + let commentCount: Int + + /// 답변의 좋아요 갯수 + var heartCount: Int + /// 초기화용 답변 엔티티 static var initialState: Answer { Answer( @@ -47,7 +56,10 @@ struct Answer: Identifiable, Equatable { publishedDate: .now, isReported: false, isMine: true, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 0, + heartCount: 0 ) } } diff --git a/Qapple/Qapple/SourceCode/Entity/AnswerComment.swift b/Qapple/Qapple/SourceCode/Entity/AnswerComment.swift index 4a6ad8c1..42aa9877 100644 --- a/Qapple/Qapple/SourceCode/Entity/AnswerComment.swift +++ b/Qapple/Qapple/SourceCode/Entity/AnswerComment.swift @@ -7,7 +7,7 @@ import Foundation -// MARK: 임시 Entity + struct AnswerComment: Identifiable, Equatable { let id: Int let writeId: Int diff --git a/Qapple/Qapple/SourceCode/Feature/1.MainFlow/MainFlowFeature.swift b/Qapple/Qapple/SourceCode/Feature/1.MainFlow/MainFlowFeature.swift index 22314066..614f217b 100644 --- a/Qapple/Qapple/SourceCode/Feature/1.MainFlow/MainFlowFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/1.MainFlow/MainFlowFeature.swift @@ -202,6 +202,10 @@ struct MainFlowFeature { state.path.append(.comment(.init(board: board))) return .none + case let .element(id: _, action: .myAnswerList(.commentButtonTapped(answer))): + state.path.append(.answerCommentList(.init(answer: answer))) + return .none + case .element(id: _, action: .answerList(.backButtonTapped)): state.path.removeAll() return .none diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionFeature.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionFeature.swift index 0480c398..f802c58a 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionFeature.swift @@ -38,6 +38,8 @@ struct TodayQuestionFeature { case seeAllAnswerButtonTapped(Question) case seeMoreAnswerButtonTapped(Answer) case answerCommentButtonTapped(Answer) + case likeAnswerButtonTapped(Answer) + case likeAnswer(Answer) case questionTimerTick case cancelQuestionTimer case toggleLoading(Bool) @@ -146,6 +148,30 @@ struct TodayQuestionFeature { case .answerCommentButtonTapped: return .none + case let .likeAnswerButtonTapped(answer): + return .run { send in + await send(.toggleLoading(true), animation: .bouncy) + do { + try await answerRepository.likeAnswer(answer.id) + await send(.likeAnswer(answer)) + } catch { + await send(.networkingFailed(error)) + } + await send(.toggleLoading(false), animation: .bouncy) + } + + case let .likeAnswer(answer): + guard let currentAnswerIdx = state.answerPreviewList.firstIndex(where: { $0.id == answer.id }) + else { return .none } + + if state.answerPreviewList[currentAnswerIdx].isLiked { + state.answerPreviewList[currentAnswerIdx].heartCount -= 1 + } else { + state.answerPreviewList[currentAnswerIdx].heartCount += 1 + } + state.answerPreviewList[currentAnswerIdx].isLiked.toggle() + return .none + case let .sheet(.presented(.seeMore(.alert(.presented(.confirmDeletion(sheetData)))))): guard case let .answer(answer) = sheetData else { return .none } return .run { send in diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionView.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionView.swift index 411f7727..e1e0662e 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionView.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-2.TodayQuestion/TodayQuestionView.swift @@ -308,7 +308,7 @@ private struct AnswerPreviewList: View { store.send(.seeMoreAnswerButtonTapped(answer)) }, likeAction: { - + store.send(.likeAnswerButtonTapped(answer)) }, commentAction: { store.send(.answerCommentButtonTapped(answer)) @@ -365,7 +365,10 @@ private struct SeeAllButton: View { publishedDate: .now, isReported: false, isMine: false, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ), Answer( id: 1, @@ -376,7 +379,10 @@ private struct SeeAllButton: View { publishedDate: .now, isReported: false, isMine: false, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ), Answer( id: 2, @@ -387,7 +393,10 @@ private struct SeeAllButton: View { publishedDate: .now, isReported: false, isMine: false, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ) ] ) diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListFeature.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListFeature.swift index 3cb009cf..0f809c60 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListFeature.swift @@ -17,6 +17,7 @@ struct AnswerListFeature { var answerList: [Answer] = [] var totalCount: QappleAPI.TotalCount = 0 var paginationInfo = QappleAPI.PaginationInfo(threshold: "", hasNext: false) + var popularAnswerStatus: PopularAnswerCellStatus = .none var isLoading = false @Presents var sheet: Sheet.State? @Presents var alert: AlertState? @@ -33,8 +34,10 @@ struct AnswerListFeature { case networkingFailed(Error) case seeMoreAction(Answer) case backButtonTapped - case likeAnswerButtonTapped + case likeAnswerButtonTapped(Answer) case answerCommentButtonTapped(Answer) + case fetchPopularAnswer(Answer) + case likeAnswer(Answer) case toggleLoading(Bool) case sheet(PresentationAction) case alert(PresentationAction) @@ -57,6 +60,22 @@ struct AnswerListFeature { let response = try await answerRepository.fetchAnswerListOfQuestion( question.id, nil ) + let currentHour = Calendar.current.component(.hour, from: .now) + + if question.isLived, !(currentHour > 12 && currentHour < 19){ + if !(currentHour > 12 && currentHour < 19) { + let response = try await answerRepository.fetchPopularAnswer(question) + if let answer = response.0 { + await send(.fetchPopularAnswer(answer)) + } + } + } else { + let response = try await answerRepository.fetchPopularAnswer(question) + if let answer = response.0 { + await send(.fetchPopularAnswer(answer)) + } + } + await send( .answerListResponse( response.0, @@ -115,13 +134,38 @@ struct AnswerListFeature { state.answerList = state.answerList.reversed().filter(UserDefaults.filterAnswerBlockedUser) return .none - case .likeAnswerButtonTapped: - // TODO: 좋아요 기능 구현 필요 - return .none + case let .likeAnswerButtonTapped(answer): + return .run { send in + await send(.toggleLoading(true), animation: .bouncy) + do { + try await answerRepository.likeAnswer(answer.id) + GAService.log(.likeAnswerFromList(answer: answer)) + await send(.likeAnswer(answer)) + } catch { + await send(.networkingFailed(error)) + } + await send(.toggleLoading(false), animation: .bouncy) + } case .answerCommentButtonTapped: return .none + case let .fetchPopularAnswer(answer): + state.popularAnswerStatus = .popularAnswer(answer, state.question) + return .none + + case let .likeAnswer(answer): + guard let currentAnswerIdx = state.answerList.firstIndex(where: { $0.id == answer.id }) + else { return .none } + + if state.answerList[currentAnswerIdx].isLiked { + state.answerList[currentAnswerIdx].heartCount -= 1 + } else { + state.answerList[currentAnswerIdx].heartCount += 1 + } + state.answerList[currentAnswerIdx].isLiked.toggle() + return .none + case let .networkingFailed(error): HapticService.notification(type: .error) state.alert = .failedNetworking(with: error) diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListView.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListView.swift index 0373fd3a..84040b00 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListView.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-6.AnswerList/AnswerListView.swift @@ -131,6 +131,11 @@ private struct AnswerList: View { var body: some View { ScrollView { LazyVStack { + if case let .popularAnswer(answer, question) = store.state.popularAnswerStatus { + QPPopularAnswerCell(status: .popularAnswer(answer, question)) + .padding(.horizontal, 16) + } + ForEach(enumerated(store.answerList), id: \.element.id) { index, answer in QPAnswerCell( @@ -141,7 +146,7 @@ private struct AnswerList: View { store.send(.seeMoreAction(answer)) }, likeAction: { - store.send(.likeAnswerButtonTapped) + store.send(.likeAnswerButtonTapped(answer)) }, commentAction: { store.send(.answerCommentButtonTapped(answer)) diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentFeature.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentFeature.swift index b4b5b4f4..6ea3777a 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentFeature.swift @@ -8,7 +8,7 @@ import Foundation import ComposableArchitecture -// TODO: 4/14 - Reducer 업데이트 필요 + @Reducer struct AnswerCommentFeature { @ObservableState @@ -16,7 +16,6 @@ struct AnswerCommentFeature { var answer: Answer var commentText: String = "" var commentList: [AnswerComment] = [] - var paginationInfo = QappleAPI.PaginationInfo(threshold: "", hasNext: false) var isLoading: Bool = false @Presents var sheet: Sheet.State? @Presents var alert: AlertState? @@ -26,10 +25,7 @@ struct AnswerCommentFeature { case onAppear case onDisappear case refresh - case pagination - case commentListResponse([AnswerComment], QappleAPI.PaginationInfo) - case paginationResponse([AnswerComment], QappleAPI.PaginationInfo) - case answerResponse(Answer) + case commentListResponse([AnswerComment]) case backButtonTapped case likeCommentButtonTapped(AnswerComment) @@ -57,6 +53,8 @@ struct AnswerCommentFeature { } @Dependency(\.dismiss) var dismiss + @Dependency(\.answerRepository) var answerRepository + @Dependency(\.answerCommentRepository) var answerCommentRepository var body: some ReducerOf { BindingReducer() @@ -65,47 +63,21 @@ struct AnswerCommentFeature { case .onAppear, .refresh: return .run { [answerId = state.answer.id] send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// let commentResponse = try await commentRepository.fetchBoardCommentList(boardId, nil) -// let boardResponse = try await bulletinBoardRepository.fetchSingleBoard(boardId) -// await send(.commentListResponse(commentResponse.0, commentResponse.1)) -// await send(.boardResponse(boardResponse)) -// } catch { -// await send(.networkingFailed(error)) -// } + do { + let commentResponse = try await answerCommentRepository.fetchAnswerComments(answerId) + // TODO: 5/20 단일 답변 패치 필요? + await send(.commentListResponse(commentResponse)) + } catch { + await send(.networkingFailed(error)) + } await send(.toggleLoading(false), animation: .bouncy) } case .onDisappear: return .none - case .pagination: - return .run { [ - answerId = state.answer.id, - threshold = Int(state.paginationInfo.threshold) - ] send in - await send(.toggleLoading(true), animation: .bouncy) -// do { -// let response = try await commentRepository.fetchBoardCommentList(boardId, threshold) -// await send(.paginationResponse(response.0, response.1)) -// } catch { -// await send(.networkingFailed(error)) -// } - await send(.toggleLoading(false), animation: .bouncy) - } - - case let .commentListResponse(commentList, paginationInfo): -// state.commentList = anonymizeCommentList(state.answer.writerId, commentList) -// state.paginationInfo = paginationInfo - return .none - - case let .paginationResponse(commentList, paginationInfo): -// state.commentList.append(contentsOf: anonymizeCommentList(state.answer.writerId, commentList)) -// state.paginationInfo = paginationInfo - return .none - - case let .answerResponse(answer): - state.answer = answer + case let .commentListResponse(commentList): + state.commentList = anonymizeCommentList(state.answer.writerId, commentList) return .none case .backButtonTapped: @@ -117,21 +89,21 @@ struct AnswerCommentFeature { HapticService.impact(style: .light) return .run { [answer = state.answer] send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// try await commentRepository.likeBoardComment(boardComment.id) -// await send(.likeComment(boardComment.id)) -// GAService.log(.likeBoardComment(board: board, boardComment: boardComment)) -// } catch { -// await send(.networkingFailed(error)) -// } + do { + try await answerCommentRepository.likeAnswerComment(answerComment.id) + await send(.likeComment(answerComment.id)) + GAService.log(.likeAnswerComment(answer: answer, answerComment: answerComment)) + } catch { + await send(.networkingFailed(error)) + } await send(.toggleLoading(false), animation: .bouncy) } case let .likeComment(answerCommentId): -// if let index = state.commentList.firstIndex(where: { $0.id == boardCommentId }) { -// state.commentList[index].isLiked.toggle() -// state.commentList[index].heartCount += state.commentList[index].isLiked ? 1 : -1 -// } + if let index = state.commentList.firstIndex(where: { $0.id == answerCommentId }) { + state.commentList[index].isLiked.toggle() + state.commentList[index].heartCount += state.commentList[index].isLiked ? 1 : -1 + } return .none case .uploadCommentButtonTapped: @@ -140,15 +112,15 @@ struct AnswerCommentFeature { answer = state.answer ] send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// try await commentRepository.postBoardComment(board.id, text) -// HapticService.notification(type: .success) -// GAService.log(.postBoardComment(board: board, comment: text)) -// await send(.refresh) -// await send(.commentTextReset) -// } catch { -// await send(.networkingFailed(error)) -// } + do { + try await answerCommentRepository.createAnswerComment(answer.id, text) + HapticService.notification(type: .success) + GAService.log(.postAnswerComment(answer: answer, comment: text)) + await send(.refresh) + await send(.commentTextReset) + } catch { + await send(.networkingFailed(error)) + } await send(.toggleLoading(false), animation: .bouncy) } @@ -160,9 +132,9 @@ struct AnswerCommentFeature { NotificationCenter.default.post(name: .updateCommentCellToggle, object: nil) return .none - case let .deleteCommentButtonTapped(boardComment): + case let .deleteCommentButtonTapped(answerComment): HapticService.notification(type: .error) - state.alert = .confirmDeletion(boardComment.id) + state.alert = .confirmDeletion(answerComment.id) return .none case .successDeletion: @@ -174,23 +146,23 @@ struct AnswerCommentFeature { HapticService.impact(style: .light) return .run { [answer = state.answer] send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// try await bulletinBoardRepository.likeBoard(answer.id) -// await send(.likeBoard) -// if !answer.isLiked { GAService.log(.likeBoardFromDetail(board: answer)) } -// } catch { -// await send(.networkingFailed(error)) -// } + do { + try await answerRepository.likeAnswer(answer.id) + await send(.likeAnswer) + if !answer.isLiked { GAService.log(.likeAnswerFromDetail(answer: answer)) } + } catch { + await send(.networkingFailed(error)) + } await send(.toggleLoading(false), animation: .bouncy) } case .likeAnswer: -// if state.answer.isLiked { -// state.answer.heartCount -= 1 -// } else { -// state.answer.heartCount += 1 -// } -// state.answer.isLiked.toggle() + if state.answer.isLiked { + state.answer.heartCount -= 1 + } else { + state.answer.heartCount += 1 + } + state.answer.isLiked.toggle() return .none case .seeMoreAction: @@ -218,12 +190,12 @@ struct AnswerCommentFeature { guard case let .answer(answer) = sheetData else { return .none } return .run { send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// try await bulletinBoardRepository.deleteBoard(board.id) -// await send(.sheet(.presented(.seeMore(.completionDeletion)))) -// } catch { -// await send(.networkingFailed(error)) -// } + do { + try await answerRepository.deleteAnswer(answer.id) + await send(.sheet(.presented(.seeMore(.completionDeletion)))) + } catch { + await send(.networkingFailed(error)) + } await send(.toggleLoading(false), animation: .bouncy) } @@ -248,16 +220,14 @@ struct AnswerCommentFeature { state.sheet = nil return .send(.onDisappear) - case let .alert(.presented(.confirmDeletion(answerId))): + case let .alert(.presented(.confirmDeletion(answerCommentId))): return .run { send in await send(.toggleLoading(true), animation: .bouncy) -// do { -// try await commentRepository.deleteBoardComment(boardCommentId) + do { + try await answerCommentRepository.deleteAnswerComment(answerCommentId) await send(.refresh) await send(.successDeletion) -// } catch { -// await send(.networkingFailed(error)) -// } + } await send(.toggleLoading(false), animation: .bouncy) } @@ -306,7 +276,7 @@ extension AlertState where Action == AnswerCommentFeature.Action.Alert { extension AnswerCommentFeature { // 이름을 익명화 해주는 method - private func anonymizeCommentList(_ BoardWriterId: Int, _ commentList: [AnswerComment]) -> [AnswerComment] { + private func anonymizeCommentList(_ answerWriterId: Int, _ commentList: [AnswerComment]) -> [AnswerComment] { var anonymousArray: [Int: Int] = [:] var anonymousIndex: Int = 0 @@ -316,7 +286,7 @@ extension AnswerCommentFeature { if !isContainName { anonymousIndex += 1 - let anonymityId = (comment.writeId == BoardWriterId) ? -1 : anonymousIndex + let anonymityId = (comment.writeId == answerWriterId) ? -1 : anonymousIndex anonymousArray.updateValue(comment.writeId, forKey: anonymityId) @@ -330,7 +300,7 @@ extension AnswerCommentFeature { isMine: comment.isMine, isReport: comment.isReport, createdAt: comment.createdAt, - anonymityId: (comment.writeId == BoardWriterId) ? -1 : anonymousIndex + anonymityId: (comment.writeId == answerWriterId) ? -1 : anonymousIndex ) } else { let currentIndex = anonymousArray.first(where: { $0.value == comment.writeId })?.key ?? 0 diff --git a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentView.swift b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentView.swift index 1b3385a5..8e22a174 100644 --- a/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentView.swift +++ b/Qapple/Qapple/SourceCode/Feature/2.QuestionTab/2-7.AnswerCommentList/AnswerCommentView.swift @@ -85,7 +85,7 @@ private struct CommentListView: View { var body: some View { ZStack { - VStack { + VStack(spacing: 0) { seperator HStack { @@ -94,39 +94,12 @@ private struct CommentListView: View { .foregroundStyle(.sub3) Spacer() } - .padding(.top, 12) + .padding(.vertical, 12) .padding(.horizontal, 20) ScrollView { LazyVStack(spacing: 0) { - // TODO: 4/14 데이터 연결 필요 - // ForEach(Array(self.store.commentList.enumerated()), id: \.offset) { index, comment in - // AnswerCommentCell( - // comment: comment, - // like: { - // store.send(.likeCommentButtonTapped(comment)) - // }, - // delete: { - // store.send(.deleteCommentButtonTapped(comment)) - // }, - // report: { - // store.send(.reportButtonTapped(comment)) - // } - // ) - // .configurePagination( - // store.commentList, - // currentIndex: index, - // hasNext: store.paginationInfo.hasNext, - // pagination: { - // store.send(.pagination) - // } - // ) - // .disabled(store.isLoading) - // - // seperator - // } - - ForEach(AnswerCommentFeature.sampleComment) { comment in + ForEach(Array(self.store.commentList.enumerated()), id: \.offset) { index, comment in AnswerCommentCell( comment: comment, like: { @@ -139,7 +112,8 @@ private struct CommentListView: View { store.send(.reportButtonTapped(comment)) } ) - + .disabled(store.isLoading) + seperator } } @@ -148,17 +122,17 @@ private struct CommentListView: View { .scrollDismissesKeyboard(.immediately) .background(Color.bk) -// if store.commentList.isEmpty && !store.isLoading { -// VStack { -// Text("아직 작성된 댓글이 없습니다") -// .font(.pretendard(.medium, size: 14)) -// .foregroundStyle(.sub5) -// .multilineTextAlignment(.center) -// .padding(.top, 24) -// -// Spacer() -// } -// } + if store.commentList.isEmpty && !store.isLoading { + VStack { + Text("아직 작성된 댓글이 없습니다") + .font(.pretendard(.medium, size: 14)) + .foregroundStyle(.sub5) + .multilineTextAlignment(.center) + .padding(.top, 24) + + Spacer() + } + } } } @@ -217,7 +191,10 @@ private struct AddCommentView: View { publishedDate: .init(), isReported: false, isMine: false, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ) ) ){ diff --git a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardFeature.swift b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardFeature.swift index 333b9916..80d4f179 100644 --- a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardFeature.swift @@ -17,6 +17,7 @@ struct BulletinBoardFeature { var bulletinBoardList: [BulletinBoard] = [] var todayQuestion: Question = .initialState var event: AcademyEventFor4th = .fourthStart + var popularAnswerStatus: PopularAnswerCellStatus = .none var paginationInfo = QappleAPI.PaginationInfo(threshold: "", hasNext: false) var isLoading: Bool = false var isFirstLaunch = true @@ -43,8 +44,10 @@ struct BulletinBoardFeature { case networkingFailed(Error) case toggleLoading(Bool) case filterBlockedUser + case questionNotiTapped(Question) case popularAnswerTapped(Question) + case fetchPopularAnswer((Answer?, Question?, Bool)) case sheet(PresentationAction) case alert(PresentationAction) @@ -61,6 +64,7 @@ struct BulletinBoardFeature { @Dependency(\.questionRepository.fetchMainQuestion) var fetchMainQuestion @Dependency(\.bulletinBoardRepository) var bulletinBoardRepository + @Dependency(\.answerRepository.fetchPopularAnswer) var fetchPopularAnswer var body: some ReducerOf { Reduce { state, action in @@ -74,6 +78,8 @@ struct BulletinBoardFeature { do { let mainQuestion = try await fetchMainQuestion() let response = try await bulletinBoardRepository.fetchBulletinBoardList(nil) + let popularAnswer = try await fetchPopularAnswer(nil) + await send(.fetchPopularAnswer(popularAnswer)) await send(.bulletinBoardListResponse(mainQuestion, response.0, response.1)) } catch { await send(.networkingFailed(error)) @@ -146,6 +152,15 @@ struct BulletinBoardFeature { } return .none + case let .fetchPopularAnswer(result): + if let answer = result.0, let question = result.1 { + state.popularAnswerStatus = .popularAnswer(answer, question) + } else { + state.popularAnswerStatus = result.2 ? .none : .todayQuestion + } + + return .none + case .searchButtonTapped: return .none diff --git a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardView.swift b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardView.swift index 48939ec2..7b7f100f 100644 --- a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardView.swift +++ b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-1.BulletinBoard/BulletinBoardView.swift @@ -88,79 +88,6 @@ private struct BulletinBoardContentView: View { } } -// MARK: - QuestionNotificationView - -private struct QuestionNotificationView: View { - - let store: StoreOf - - var body: some View { - if !store.todayQuestion.isAnswered { - Button { - store.send(.questionNotiTapped(store.todayQuestion)) - } label: { - HStack(spacing: 0) { - Image("questionReady") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .padding(.trailing, 6) - .padding(.leading, 18) - - Text("오늘의 질문이 도착했어요!") - .font(.pretendard(.semiBold, size: 15)) - .foregroundStyle(.white) - - Spacer() - } - .frame(width: 361, height: 47) - .background(RoundedRectangle(cornerRadius: 12) - .fill(.questionNoti) - .stroke(.button.opacity(0.17), lineWidth: 0.6)) // TODO: 그라데이션 - } - } else { - Button { - // store.send(.popularAnswerTapped(store.todayQuestion)) - } label: { - HStack(spacing: 0) { - VStack(spacing: 0) { - Image("questionComplete") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .padding(.trailing, 8) - .padding(.leading, 18) - - Spacer() - } - - - VStack(alignment: .leading, spacing: 0) { -// Text("오늘의 인기 답변") -// .font(.pretendard(.regular, size: 12)) -// .foregroundStyle(TextLabel.sub4) -// -// Spacer() - - Text("이전에 개발자의 실수로 이상한(?) 정보가 표시됐었답니다.") // TODO: 인기 답변으로 - .lineLimit(2) - .multilineTextAlignment(.leading) - .font(.pretendard(.regular, size: 15)) - .foregroundStyle(.white) - } - - Spacer() - } - .padding(.vertical, 12) - .frame(width: 361, height: 67) - .background(RoundedRectangle(cornerRadius: 12) - .fill(.questionNoti) - .stroke(.button.opacity(0.17), lineWidth: 0.6)) // TODO: 그라데이션 - } - } - } -} - // MARK: - BulletionBoardListView private struct BulletionBoardListView: View { @@ -169,10 +96,24 @@ private struct BulletionBoardListView: View { var body: some View { ScrollView { - - QuestionNotificationView(store: store) - .padding(.horizontal) - .padding(.top, 2) + Group { + switch store.state.popularAnswerStatus { + case .todayQuestion: + QPPopularAnswerCell(status: .todayQuestion) + .onTapGesture { + store.send(.questionNotiTapped(store.todayQuestion)) + } + case let .popularAnswer(answer, question): + QPPopularAnswerCell(status: .popularAnswer(answer, question)) + .onTapGesture { + store.send(.popularAnswerTapped(question)) + } + case .none: + EmptyView() + } + } + .padding(.horizontal) + .padding(.top, 2) LazyVStack(spacing: 0) { ForEach(enumerated(store.bulletinBoardList), id: \.offset) { index, board in diff --git a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-4.BoardComment/BoardCommentFeature.swift b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-4.BoardComment/BoardCommentFeature.swift index b2c3dce1..a238993e 100644 --- a/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-4.BoardComment/BoardCommentFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/3.BulletinBoard/3-4.BoardComment/BoardCommentFeature.swift @@ -55,7 +55,7 @@ struct BoardCommentFeature { } } - @Dependency(\.commentRepository) var commentRepository + @Dependency(\.boardCommentRepository) var boardCommentRepository @Dependency(\.bulletinBoardRepository) var bulletinBoardRepository @Dependency(\.dismiss) var dismiss @@ -67,7 +67,7 @@ struct BoardCommentFeature { return .run { [boardId = state.board.id] send in await send(.toggleLoading(true), animation: .bouncy) do { - let commentResponse = try await commentRepository.fetchBoardCommentList(boardId, nil) + let commentResponse = try await boardCommentRepository.fetchBoardCommentList(boardId, nil) let boardResponse = try await bulletinBoardRepository.fetchSingleBoard(boardId) await send(.commentListResponse(commentResponse.0, commentResponse.1)) await send(.boardResponse(boardResponse)) @@ -87,7 +87,7 @@ struct BoardCommentFeature { ] send in await send(.toggleLoading(true), animation: .bouncy) do { - let response = try await commentRepository.fetchBoardCommentList(boardId, threshold) + let response = try await boardCommentRepository.fetchBoardCommentList(boardId, threshold) await send(.paginationResponse(response.0, response.1)) } catch { await send(.networkingFailed(error)) @@ -119,7 +119,7 @@ struct BoardCommentFeature { return .run { [board = state.board] send in await send(.toggleLoading(true), animation: .bouncy) do { - try await commentRepository.likeBoardComment(boardComment.id) + try await boardCommentRepository.likeBoardComment(boardComment.id) await send(.likeComment(boardComment.id)) GAService.log(.likeBoardComment(board: board, boardComment: boardComment)) } catch { @@ -142,7 +142,7 @@ struct BoardCommentFeature { ] send in await send(.toggleLoading(true), animation: .bouncy) do { - try await commentRepository.postBoardComment(board.id, text) + try await boardCommentRepository.postBoardComment(board.id, text) HapticService.notification(type: .success) GAService.log(.postBoardComment(board: board, comment: text)) await send(.refresh) @@ -253,7 +253,7 @@ struct BoardCommentFeature { return .run { send in await send(.toggleLoading(true), animation: .bouncy) do { - try await commentRepository.deleteBoardComment(boardCommentId) + try await boardCommentRepository.deleteBoardComment(boardCommentId) await send(.refresh) await send(.successDeletion) } catch { @@ -307,7 +307,7 @@ extension AlertState where Action == BoardCommentFeature.Action.Alert { extension BoardCommentFeature { // 이름을 익명화 해주는 method - private func anonymizeCommentList(_ BoardWriterId: Int, _ commentList: [BoardComment]) -> [BoardComment] { + private func anonymizeCommentList(_ boardWriterId: Int, _ commentList: [BoardComment]) -> [BoardComment] { var anonymousArray: [Int: Int] = [:] var anonymousIndex: Int = 0 @@ -317,7 +317,7 @@ extension BoardCommentFeature { if !isContainName { anonymousIndex += 1 - let anonymityId = (comment.writeId == BoardWriterId) ? -1 : anonymousIndex + let anonymityId = (comment.writeId == boardWriterId) ? -1 : anonymousIndex anonymousArray.updateValue(comment.writeId, forKey: anonymityId) @@ -331,7 +331,7 @@ extension BoardCommentFeature { isMine: comment.isMine, isReport: comment.isReport, createdAt: comment.createdAt, - anonymityId: (comment.writeId == BoardWriterId) ? -1 : anonymousIndex + anonymityId: (comment.writeId == boardWriterId) ? -1 : anonymousIndex ) } else { let currentIndex = anonymousArray.first(where: { $0.value == comment.writeId })?.key ?? 0 diff --git a/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListFeature.swift b/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListFeature.swift index 092a8e72..220fbc05 100644 --- a/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListFeature.swift @@ -25,6 +25,9 @@ struct MyAnswerListFeature { case onAppear case refresh case pagination + case likeAnswerButtonTapped(Answer) + case commentButtonTapped(Answer) + case likeAnswer(Answer) case answerListResponse( [Answer], QappleAPI.PaginationInfo @@ -71,6 +74,33 @@ struct MyAnswerListFeature { await send(.toggleLoading(false), animation: .bouncy) } + case let .likeAnswerButtonTapped(answer): + return .run { send in + await send(.toggleLoading(true), animation: .bouncy) + do { + try await answerRepository.likeAnswer(answer.id) + await send(.likeAnswer(answer)) + } catch { + await send(.networkingFailed(error)) + } + await send(.toggleLoading(false), animation: .bouncy) + } + + case let .likeAnswer(answer): + guard let currentAnswerIdx = state.myAnswerList.firstIndex(where: { $0.id == answer.id }) + else { return .none } + + if state.myAnswerList[currentAnswerIdx].isLiked { + state.myAnswerList[currentAnswerIdx].heartCount -= 1 + } else { + state.myAnswerList[currentAnswerIdx].heartCount += 1 + } + state.myAnswerList[currentAnswerIdx].isLiked.toggle() + return .none + + case .commentButtonTapped: + return .none + case let .answerListResponse(answerList, paginationInfo): state.myAnswerList = answerList state.paginationInfo = paginationInfo diff --git a/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListView.swift b/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListView.swift index 53a8bc86..ee498522 100644 --- a/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListView.swift +++ b/Qapple/Qapple/SourceCode/Feature/5.Profile/5-3.MyAnswerList/MyAnswerListView.swift @@ -72,10 +72,10 @@ private struct MyAnswerList: View { store.send(.seeMoreAction(answer)) }, likeAction: { - + store.send(.likeAnswerButtonTapped(answer)) }, commentAction: { - + store.send(.commentButtonTapped(answer)) } ) .configurePagination( diff --git a/Qapple/Qapple/SourceCode/Feature/8.Report/ReportFeature.swift b/Qapple/Qapple/SourceCode/Feature/8.Report/ReportFeature.swift index b8e786e9..a8496161 100644 --- a/Qapple/Qapple/SourceCode/Feature/8.Report/ReportFeature.swift +++ b/Qapple/Qapple/SourceCode/Feature/8.Report/ReportFeature.swift @@ -60,7 +60,7 @@ struct ReportFeature { case let .comment(comment): try await reportRepository.reportComment(comment.id, reportType) case let .answerComment(comment): - // TODO: 답면 댓글 신고 구현 + // TODO: 답변 댓글 신고 구현 break } diff --git a/Qapple/Qapple/SourceCode/UIComponent/QPAnswerCell.swift b/Qapple/Qapple/SourceCode/UIComponent/QPAnswerCell.swift index 53281cda..0e875155 100644 --- a/Qapple/Qapple/SourceCode/UIComponent/QPAnswerCell.swift +++ b/Qapple/Qapple/SourceCode/UIComponent/QPAnswerCell.swift @@ -84,12 +84,11 @@ private struct NormalCell: View { Content() .padding(.top, 8) .padding(.horizontal, 16) - .padding(.bottom, 20) + .padding(.bottom, 5) -// Footer() -// .padding(.top, 6) -// .padding(.bottom, 20) -// .padding(.horizontal, 16) + Footer() + .padding(.bottom, 14) + .padding(.horizontal, 16) } .background(.first) } @@ -156,11 +155,11 @@ private struct NormalCell: View { likeAction() } label: { HStack(spacing: 4) { - Image(true ? .heartActive : .heart) + Image(answer.isLiked ? .heartActive : .heart) .resizable() .frame(width: 18, height: 18) - Text("32") + Text("\(answer.heartCount)") .pretendard(.regular, 13) .foregroundStyle(.sub3) } @@ -175,7 +174,7 @@ private struct NormalCell: View { .resizable() .frame(width: 15, height: 14) - Text("32") + Text("\(answer.commentCount)") .pretendard(.regular, 13) } .foregroundStyle(.sub3) @@ -315,7 +314,10 @@ private struct ReportedCell: View { publishedDate: .now, isReported: false, isMine: false, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ), Answer( id: 1, @@ -326,7 +328,10 @@ private struct ReportedCell: View { publishedDate: .now, isReported: false, isMine: true, - isResignMember: false + isLiked: false, + isResignMember: false, + commentCount: 1, + heartCount: 1 ), Answer( id: 2, @@ -337,7 +342,10 @@ private struct ReportedCell: View { publishedDate: .now, isReported: false, isMine: false, - isResignMember: true + isLiked: true, + isResignMember: false, + commentCount: 1, + heartCount: 1 ), Answer( id: 3, @@ -348,7 +356,10 @@ private struct ReportedCell: View { publishedDate: .now, isReported: true, isMine: false, - isResignMember: false + isLiked: true, + isResignMember: false, + commentCount: 1, + heartCount: 1 ) ] ZStack { diff --git a/Qapple/Qapple/SourceCode/UIComponent/QPPopularAnswerCell.swift b/Qapple/Qapple/SourceCode/UIComponent/QPPopularAnswerCell.swift new file mode 100644 index 00000000..09f7dd5b --- /dev/null +++ b/Qapple/Qapple/SourceCode/UIComponent/QPPopularAnswerCell.swift @@ -0,0 +1,79 @@ +// +// QPPopularAnswerCell.swift +// Qapple +// +// Created by 문인범 on 5/19/25. +// + +import SwiftUI + + +enum PopularAnswerCellStatus: Equatable { + case none + case todayQuestion + case popularAnswer(Answer, Question) +} + +struct QPPopularAnswerCell: View { + let status: PopularAnswerCellStatus + + var body: some View { + HStack(alignment: .top, spacing: 0) { + Image(status == .todayQuestion ? .questionReady : .questionComplete) + .resizable() + .frame(width: 22, height: 21) + .padding(.vertical, 13) + .padding(.leading, 18) + + switch status { + case .todayQuestion: + Text("오늘의 질문이 도착했어요!") + .font(.pretendard(.semiBold, size: 15)) + .foregroundStyle(.main) + .padding(.top, 15) + .padding(.leading, 6) + case let .popularAnswer(answer, question): + VStack(alignment: .leading, spacing: 5) { + Text(question.isLived ? "오늘의 인기 답변" : "인기 답변") + .font(.pretendard(.light, size: 12)) + .foregroundStyle(.main.opacity(0.5)) + + Text(answer.content) + .font(.pretendard(.regular, size: 15)) + .foregroundStyle(.main) + .lineLimit(1) + } + .padding(.vertical, 13) + .padding(.leading, 7.5) + + case .none: + EmptyView() + } + + Spacer() + } + .background { + ZStack { + RoundedRectangle(cornerRadius: 12) + .foregroundStyle(.questionNoti) + + + RoundedRectangle(cornerRadius: 12) + .stroke(lineWidth: 1) + .foregroundStyle( + RadialGradient(colors: [.popularStart, .popularEnd], center: .center, startRadius: 0, endRadius: 100) + .opacity(0.17) + + ) + } + } + } +} + + +#Preview { + VStack { + QPPopularAnswerCell(status: .todayQuestion) + } + .padding(.horizontal, 10) +}