From 1a510367f80de2057e3f7d3dae80e86584f85684 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 00:48:49 +0900 Subject: [PATCH 01/42] =?UTF-8?q?feat:=20=EC=B5=9C=EC=A2=85=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20API(/votes/post)=20=EC=97=B0=EB=8F=99=20+=20ChatRoo?= =?UTF-8?q?m=20=EC=99=84=EC=B2=AD=20=ED=9B=84=20=EC=B5=9C=EC=A2=85?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=ED=99=94=EB=A9=B4=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BattleAPI / BattleService.postVote(battleId:, body:) 추가 - BattleInterface.submitPostVote + Default/Repository 구현 (PreVoteResult/PreVoteRequest 재사용) - PreVoteFeature.VoteMode (.pre / .post) 신설 - State.voteMode + State.primaryButtonTitle 으로 CTA 텍스트 분기 - primaryButtonTapped 가 voteMode 별로 submitPreVote / submitPostVote 디스패치 - AsyncAction.submitPostVote + InnerAction.postVoteResponse + CancelID.submitPostVote - 성공/실패 모두 delegate.voteSubmitted 로 동일하게 알림 - ChatRoomFeature.DelegateAction.requestFinalVote(battleId) 추가 - customAlert.confirmTapped 시 audioPlayer.pause + delegate.requestFinalVote 발행 - ChatCoordinator: chatRoom.requestFinalVote 수신 시 .preVote(.init(battleId:, voteMode: .post)) push - 모든 switch 분기에 explicit return 적용 --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 ++ .../Sources/Battle/BattleRepositoryImpl.swift | 14 ++++++ .../Sources/Battle/BattleService.swift | 7 +++ .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 4 ++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 9 +++- .../Coordinator/Reducer/ChatCoordinator.swift | 6 ++- .../Sources/Vote/Reducer/PreVoteFeature.swift | 50 ++++++++++++++++++- .../Chat/Sources/Vote/View/PreVoteView.swift | 2 +- 9 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 35d34d54..e46472cc 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -8,6 +8,7 @@ import Foundation public enum BattleAPI { case detail(battleId: Int) case preVote(battleId: Int) + case postVote(battleId: Int) case scenario(battleId: Int) public var description: String { @@ -16,6 +17,8 @@ public enum BattleAPI { "\(battleId)" case let .preVote(battleId): "\(battleId)/votes/pre" + case let .postVote(battleId): + "\(battleId)/votes/post" case let .scenario(battleId): "\(battleId)/scenario" } diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 4abab601..7d2d6b9a 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -52,6 +52,20 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + public func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + let dto: PreVoteResponseDTO = try await provider.request( + .postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "최종 투표 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty postVote payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } + public func fetchScenario(battleId: Int) async throws -> BattleScenario { let dto: BattleScenarioResponseDTO = try await provider.request( .scenario(battleId: battleId) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 988bea3d..eb657b4f 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -13,6 +13,7 @@ import AsyncMoya public enum BattleService { case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) + case postVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) } @@ -27,6 +28,8 @@ extension BattleService: BaseTargetType { BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): BattleAPI.preVote(battleId: battleId).description + case let .postVote(battleId, _): + BattleAPI.postVote(battleId: battleId).description case let .scenario(battleId): BattleAPI.scenario(battleId: battleId).description } @@ -40,6 +43,8 @@ extension BattleService: BaseTargetType { .get case .preVote: .post + case .postVote: + .post case .scenario: .get } @@ -51,6 +56,8 @@ extension BattleService: BaseTargetType { nil case let .preVote(_, body): body.toDictionary + case let .postVote(_, body): + body.toDictionary case .scenario: nil } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 09f7d8b3..ed654f39 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -10,6 +10,7 @@ import WeaveDI public protocol BattleInterface: Sendable { func fetchBattle(battleId: Int) async throws -> BattleDetail func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult + func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult func fetchScenario(battleId: Int) async throws -> BattleScenario } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 5d5894ac..08e4a1c1 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -32,6 +32,10 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { ) } + public func submitPostVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + PreVoteResult(voteId: 0, status: .none) + } + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 6b28d2ee..fb15dcb6 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -191,6 +191,7 @@ public struct ChatRoomFeature { public enum DelegateAction: Equatable { case dismiss + case requestFinalVote(battleId: Int) } nonisolated enum CancelID: Hashable { @@ -391,7 +392,11 @@ extension ChatRoomFeature { switch customAlertAction { case .confirmTapped: state.customAlert = nil - return .none + let battleId = state.battleId + return .run { [player = audioPlayer] send in + await player.pause() + await send(.delegate(.requestFinalVote(battleId: battleId))) + } case .cancelTapped: state.customAlert = nil state.currentTime = 0 @@ -410,7 +415,7 @@ extension ChatRoomFeature { private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { switch action { - case .dismiss: + case .dismiss, .requestFinalVote: .none } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 746a8e6b..ddcc7394 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -78,6 +78,10 @@ extension ChatCoordinator { case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): return .send(.view(.backAction)) + case let .routeAction(_, action: .chatRoom(.delegate(.requestFinalVote(battleId)))): + state.routes.push(.preVote(.init(battleId: battleId, voteMode: .post))) + return .none + default: return .none } @@ -103,7 +107,7 @@ extension ChatCoordinator { ) -> Effect { switch action { case .dismiss: - return .none + .none } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 3f3e06ce..46cc8e43 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -16,6 +16,11 @@ import LogMacro public struct PreVoteFeature { public init() {} + public enum VoteMode: Equatable { + case pre + case post + } + @ObservableState public struct State: Equatable { public var battle: PreVoteBattle? @@ -25,14 +30,27 @@ public struct PreVoteFeature { public var isSubmitting: Bool = false public var shareItem: ShareItem? public var battleId: Int + public var voteMode: VoteMode public var isPrimaryButtonEnabled: Bool { selectedOptionId != nil && !isSubmitting } - public init(battleId: Int = 0, battle: PreVoteBattle? = nil) { + public var primaryButtonTitle: String { + switch voteMode { + case .pre: "사전 투표하기" + case .post: "최종 투표하기" + } + } + + public init( + battleId: Int = 0, + battle: PreVoteBattle? = nil, + voteMode: VoteMode = .pre + ) { self.battleId = battleId self.battle = battle + self.voteMode = voteMode } } @@ -67,11 +85,13 @@ public struct PreVoteFeature { public enum AsyncAction: Equatable { case fetchBattleDetail case submitPreVote(battleId: Int, optionId: Int) + case submitPostVote(battleId: Int, optionId: Int) } public enum InnerAction: Equatable { case battleDetailResponse(Result) case preVoteResponse(Result) + case postVoteResponse(Result) } public enum DelegateAction: Equatable { @@ -82,6 +102,7 @@ public struct PreVoteFeature { nonisolated enum CancelID: Hashable { case fetchBattleDetail case submitPreVote + case submitPostVote } @Dependency(\.battleRepository) private var battleRepository @@ -139,7 +160,12 @@ extension PreVoteFeature { case .primaryButtonTapped: guard let optionId = state.selectedOptionId else { return .none } state.isSubmitting = true - return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) + switch state.voteMode { + case .pre: + return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) + case .post: + return .send(.async(.submitPostVote(battleId: state.battleId, optionId: optionId))) + } } } @@ -169,6 +195,16 @@ extension PreVoteFeature { return await send(.inner(.preVoteResponse(result))) } .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) + + case let .submitPostVote(battleId, optionId): + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.submitPostVote(battleId: battleId, optionId: optionId) + } + .mapError(AuthError.from) + return await send(.inner(.postVoteResponse(result))) + } + .cancellable(id: CancelID.submitPostVote, cancelInFlight: true) } } @@ -197,6 +233,16 @@ extension PreVoteFeature { Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) } + + case let .postVoteResponse(result): + state.isSubmitting = false + switch result { + case let .success(voteResult): + return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: voteResult))) + case let .failure(error): + Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)") + return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) + } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 683621f8..8d5b839f 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -314,7 +314,7 @@ extension PreVoteView { private var primaryButton: some View { CustomButton( action: { send(.primaryButtonTapped) }, - title: "사전 투표하기", + title: store.primaryButtonTitle, config: CustomButtonConfig.primary(.large, height: Self.ctaHeight), isEnable: store.isPrimaryButtonEnabled ) From 2807ab071b12ea79742d64dd364a9670af522d88 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 01:01:26 +0900 Subject: [PATCH 02/42] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8B=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=91=EB=8B=B5=20option.label=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=94=94=EC=BD=94=EB=94=A9=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일부 battle 응답에서 options[].label 이 누락되어 BattleOptionDTO 디코딩 실패하던 문제 수정 - BattleOptionDTO.label 을 Optional 로 변경 - 매퍼에서 nil 일 때 빈 문자열 fallback (Entity 의 label 은 표시용으로 직접 사용되지 않으므로 안전) --- .../Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift | 2 +- .../Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift index d10276cd..66e67cbf 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -30,7 +30,7 @@ public struct BattleInfoDTO: Decodable { public struct BattleOptionDTO: Decodable { public let optionId: Int - public let label: String + public let label: String? public let title: String public let stance: String public let representative: String diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift index b356d464..9dfead44 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -41,7 +41,7 @@ public extension BattleOptionDTO { func toDomain() -> BattleOption { BattleOption( optionId: optionId, - label: label, + label: label ?? "", title: title, stance: stance, representative: representative, From b4c99b17fe50818f0457daebc70b15c2a9df9d0c Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 01:08:52 +0900 Subject: [PATCH 03/42] =?UTF-8?q?fix:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=9D=91=EB=8B=B5=20philosophers.label=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=94=94=EC=BD=94=EB=94=A9=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일부 시나리오 응답에서 philosophers[].label 이 누락되어 ScenarioPhilosopherDTO 디코딩 실패하던 문제 수정 - ScenarioPhilosopherDTO.label 을 Optional 로 변경 - 매퍼에서 인덱스 기반 폴백(A/B/C/D…) 적용해 ChatRoomFeature 의 label 매칭이 계속 유효하도록 보장 --- .../Battle/DTO/BattleScenarioDataDTO.swift | 2 +- .../Mapper/BattleScenarioDataDTO+.swift | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift index 1468bed8..e0235122 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift @@ -17,7 +17,7 @@ public struct BattleScenarioDataDTO: Decodable { } public struct ScenarioPhilosopherDTO: Decodable { - public let label: String + public let label: String? public let name: String public let stance: String public let imageUrl: String diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift index 0f9f1e2d..15981606 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift @@ -11,7 +11,9 @@ public extension BattleScenarioDataDTO { BattleScenario( battleId: battleId, title: title, - philosophers: philosophers.map { $0.toDomain() }, + philosophers: philosophers.enumerated().map { idx, dto in + dto.toDomain(fallbackLabel: Self.fallbackLabel(for: idx)) + }, isInteractive: isInteractive, startNodeId: startNodeId, recommendedPathKey: RecommendedPathKey(rawValue: recommendedPathKey), @@ -19,11 +21,22 @@ public extension BattleScenarioDataDTO { nodes: nodes.map { $0.toDomain() } ) } + + /// 응답에서 label 이 누락된 경우 사용할 인덱스 기반 폴백 (A/B/C/D…). + private static func fallbackLabel(for index: Int) -> String { + guard let scalar = Unicode.Scalar(0x41 + index) else { return "" } + return String(Character(scalar)) + } } public extension ScenarioPhilosopherDTO { - func toDomain() -> ScenarioPhilosopher { - ScenarioPhilosopher(label: label, name: name, stance: stance, imageUrl: imageUrl) + func toDomain(fallbackLabel: String) -> ScenarioPhilosopher { + ScenarioPhilosopher( + label: label ?? fallbackLabel, + name: name, + stance: stance, + imageUrl: imageUrl + ) } } From 0a934d00d0557a0b418de2e210edf901a41474a4 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 01:20:11 +0900 Subject: [PATCH 04/42] =?UTF-8?q?refactor:=20ChatRoomView=20metric=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=A0=95=EB=A6=AC=20+=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=B4=ED=83=88=20=EC=8B=9C=20=EC=9D=8C=EC=9B=90?= =?UTF-8?q?=20=EC=A0=95=EC=A7=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomView 의 layout 상수(bubbleMaxWidth / avatarSize / avatarImageWidth / avatarImageHeight) 를 private enum Metric 으로 묶음. 호출처 모두 Self → Metric 으로 정리 - ChatRoomFeature.View 에 onDisappear 추가 - audioObserver(.audioObserver) cancel + audioPlayer.pause + isPlaying = false - swipe back / programmatic dismiss 등 backButton 외 경로로 화면을 나가도 음원이 멈춤 - ChatRoomView 에 .onDisappear { send(.onDisappear) } 연결 --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 8 +++++++ .../Sources/ChatRoom/View/ChatRoomView.swift | 22 +++++++++++-------- .../Sources/Vote/Reducer/PreVoteFeature.swift | 2 +- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index fb15dcb6..47a80015 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -162,6 +162,7 @@ public struct ChatRoomFeature { @CasePathable public enum View { case onAppear + case onDisappear case backButtonTapped case refreshTapped case togglePlayTapped @@ -239,6 +240,13 @@ extension ChatRoomFeature { ? subscribe.merge(with: .send(.async(.fetchScenario))) : subscribe + case .onDisappear: + state.isPlaying = false + return .merge( + .cancel(id: CancelID.audioObserver), + .run { [player = audioPlayer] _ in await player.pause() } + ) + case .backButtonTapped: return .run { [player = audioPlayer] send in await player.pause() diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index 1ad25712..77ec90ab 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -17,10 +17,13 @@ import Kingfisher @ViewAction(for: ChatRoomFeature.self) public struct ChatRoomView: View { @Bindable public var store: StoreOf - private static let bubbleMaxWidth: CGFloat = 222 - private static let avatarSize: CGFloat = 32.7 - private static let avatarImageWidth: CGFloat = 24 - private static let avatarImageHeight: CGFloat = 28 + + private enum Metric { + static let bubbleMaxWidth: CGFloat = 222 + static let avatarSize: CGFloat = 32.7 + static let avatarImageWidth: CGFloat = 24 + static let avatarImageHeight: CGFloat = 28 + } public init(store: StoreOf) { self.store = store @@ -46,6 +49,7 @@ public struct ChatRoomView: View { .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } + .onDisappear { send(.onDisappear) } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } @@ -166,12 +170,12 @@ extension ChatRoomView { private func avatar(_ speaker: ChatSpeaker) -> some View { KFImage(URL(string: speaker.imageURL ?? "")) .placeholder { - SkeletonView(cornerRadius: Self.avatarSize / 2) + SkeletonView(cornerRadius: Metric.avatarSize / 2) } .resizable() .scaledToFit() - .frame(width: Self.avatarImageWidth, height: Self.avatarImageHeight) - .frame(width: Self.avatarSize, height: Self.avatarSize) + .frame(width: Metric.avatarImageWidth, height: Metric.avatarImageHeight) + .frame(width: Metric.avatarSize, height: Metric.avatarSize) .background(.beige600, in: Circle()) } @@ -189,7 +193,7 @@ extension ChatRoomView { } } } - .frame(maxWidth: Self.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) + .frame(maxWidth: Metric.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) } @ViewBuilder @@ -200,7 +204,7 @@ extension ChatRoomView { .lineSpacing(13 * 0.4) .padding(.horizontal, 8) .padding(.vertical, 6) - .frame(maxWidth: Self.bubbleMaxWidth, alignment: .leading) + .frame(maxWidth: Metric.bubbleMaxWidth, alignment: .leading) .background( side == .left ? Color.beige50 : Color.beige400, in: RoundedRectangle(cornerRadius: 2) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 46cc8e43..cc986c69 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -164,7 +164,7 @@ extension PreVoteFeature { case .pre: return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) case .post: - return .send(.async(.submitPostVote(battleId: state.battleId, optionId: optionId))) + return .none } } } From 8f3badebb4e5f6498b5e9d92d0df781afa017415 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 03:57:38 +0900 Subject: [PATCH 05/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80(Comment)=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=93=9C=20+?= =?UTF-8?q?=20ChatCoordinator=20postVote=20=E2=86=92=20Comment=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain/Entity/Home/Comment.swift 신설 - Comment / CommentAuthor / CommentSortType / CommentTab 모델 - Chat 모듈에 Comment Feature/View 디렉터리 추가 (mock 단계) - ChatCoordinator.ChatScreen.comment(CommentFeature) 케이스 추가 - preVote.voteSubmitted 시 voteMode 분기: - .pre → chatRoom push - .post → comment push (최종 투표 후 댓글 화면 진입) - comment.delegate.dismiss 시 backAction - ChatCoordinatorView 에 comment 케이스 렌더 - PreVoteFeature.DelegateAction.voteSubmitted 시그니처에 voteMode 추가 - ChatRoomFeature - hasFinishedListening 을 UserDefaults 로 영속화해 재진입 시 시킹 즉시 허용 - CustomConfirmationPopupView 정비 (디자인 토큰 / 패딩 정렬) --- .../Domain/Entity/Sources/Home/Comment.swift | 79 ++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 20 +- .../Comment/Reducer/CommentFeature.swift | 296 +++++++++++++ .../Sources/Comment/View/CommentView.swift | 403 ++++++++++++++++++ .../Coordinator/Reducer/ChatCoordinator.swift | 13 +- .../View/ChatCoordinatorView.swift | 3 + .../Sources/Vote/Reducer/PreVoteFeature.swift | 48 ++- .../CustomConfirmationPopupView.swift | 3 +- 8 files changed, 845 insertions(+), 20 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Home/Comment.swift create mode 100644 Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift create mode 100644 Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift diff --git a/Projects/Domain/Entity/Sources/Home/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment.swift new file mode 100644 index 00000000..069d5130 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment.swift @@ -0,0 +1,79 @@ +// +// Comment.swift +// Entity +// +// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. +// + +import Foundation + +public struct CommentAuthor: Equatable, Hashable { + public let name: String + public let imageURL: String? + public let optionLabel: String? + + public init(name: String, imageURL: String? = nil, optionLabel: String? = nil) { + self.name = name + self.imageURL = imageURL + self.optionLabel = optionLabel + } +} + +public struct Comment: Equatable, Identifiable, Hashable { + public let id: Int + public let author: CommentAuthor + public let text: String + public let createdAt: Date + public let likeCount: Int + public let replyCount: Int + public let isLiked: Bool + + public init( + id: Int, + author: CommentAuthor, + text: String, + createdAt: Date, + likeCount: Int, + replyCount: Int, + isLiked: Bool + ) { + self.id = id + self.author = author + self.text = text + self.createdAt = createdAt + self.likeCount = likeCount + self.replyCount = replyCount + self.isLiked = isLiked + } +} + +public enum CommentSortType: String, Equatable, Hashable, CaseIterable { + case popular = "POPULAR" + case latest = "LATEST" + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} + +public enum CommentTab: Equatable, Hashable { + case all + case option(label: String, title: String) + + public var title: String { + switch self { + case .all: "전체" + case let .option(_, title): title + } + } + + public var optionLabel: String? { + switch self { + case .all: nil + case let .option(label, _): label + } + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 47a80015..a2a4c74c 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -26,7 +26,7 @@ public struct ChatRoomFeature { public var playerDuration: TimeInterval = 0 public var battleId: Int = 0 public var isLoadingScenario: Bool = false - /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 + /// 한 번 끝까지 재생된 콘텐츠는 이후 재진입 시 시킹/건너뛰기를 허용한다. public var hasFinishedListening: Bool = false public var hasPresentedFinalVoteAlert: Bool = false @Presents public var customAlert: CustomAlertState? @@ -147,6 +147,15 @@ public struct ChatRoomFeature { public init(battleId: Int = 0) { self.battleId = battleId + hasFinishedListening = Self.hasListenedBefore(battleId: battleId) + } + + private static func hasListenedBefore(battleId: Int) -> Bool { + UserDefaults.standard.bool(forKey: listenedKey(battleId: battleId)) + } + + fileprivate static func listenedKey(battleId: Int) -> String { + "picke.chatRoom.hasFinishedListening.\(battleId)" } } @@ -303,7 +312,6 @@ extension ChatRoomFeature { state.currentNodeId = option.nextNodeId state.selectedOptionLabel = nil state.currentTime = 0 - state.hasFinishedListening = false state.isPlaying = false return .run { [player = audioPlayer] _ in await player.pause() @@ -374,10 +382,12 @@ extension ChatRoomFeature { case let .playerTimeUpdated(time): state.currentTime = time if state.totalDuration > 0, - time >= state.totalDuration - 0.5, - !state.hasFinishedListening + time >= state.totalDuration - 0.5 { - state.hasFinishedListening = true + if !state.hasFinishedListening { + state.hasFinishedListening = true + UserDefaults.standard.set(true, forKey: State.listenedKey(battleId: state.battleId)) + } state.isPlaying = false if !state.hasPresentedFinalVoteAlert { state.hasPresentedFinalVoteAlert = true diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift new file mode 100644 index 00000000..9bd70818 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -0,0 +1,296 @@ +// +// CommentFeature.swift +// Chat +// +// 댓글 화면 mock 상태. 서버 API 가 붙기 전까지 화면/상호작용 형태를 고정한다. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct CommentFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var battleId: Int + public var title: String + public var voteSummary: VoteSummary + public var selectedFilter: CommentFilter = .all + public var selectedSort: CommentSort = .popular + public var comments: [CommentItem] + public var commentText: String = "" + + public var filteredComments: [CommentItem] { + switch selectedFilter { + case .all: + comments + case .optionA: + comments.filter { $0.option == .a } + case .optionB: + comments.filter { $0.option == .b } + } + } + + public var isSendEnabled: Bool { + !commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public init( + battleId: Int = 0, + title: String = "원가 18만 원 명품은 사기다", + voteSummary: VoteSummary = .mock, + comments: [CommentItem] = CommentItem.mocks + ) { + self.battleId = battleId + self.title = title + self.voteSummary = voteSummary + self.comments = comments + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backButtonTapped + case shareTapped + case filterTapped(CommentFilter) + case sortTapped(CommentSort) + case moreTapped(UUID) + case replyTapped(UUID) + case likeTapped(UUID) + case sendTapped + } + + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.commentText): + if state.commentText.count > 200 { + state.commentText = String(state.commentText.prefix(200)) + } + return .none + + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .delegate: + return .none + } + } + } +} + +extension CommentFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .shareTapped, .moreTapped, .replyTapped: + return .none + + case let .filterTapped(filter): + state.selectedFilter = filter + return .none + + case let .sortTapped(sort): + state.selectedSort = sort + switch sort { + case .popular: + state.comments.sort { $0.likeCount > $1.likeCount } + case .latest: + state.comments.sort { $0.createdOrder > $1.createdOrder } + } + return .none + + case let .likeTapped(id): + guard let index = state.comments.firstIndex(where: { $0.id == id }) else { return .none } + state.comments[index].isLiked.toggle() + state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 + return .none + + case .sendTapped: + let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return .none } + state.comments.insert( + CommentItem( + author: "나", + timeAgo: "방금 전", + option: .a, + content: text, + replyCount: 0, + likeCount: 0, + createdOrder: (state.comments.map(\.createdOrder).max() ?? 0) + 1 + ), + at: 0 + ) + state.commentText = "" + return .none + } + } +} + +public enum CommentFilter: String, CaseIterable, Equatable { + case all + case optionA + case optionB + + public var title: String { + switch self { + case .all: "전체" + case .optionA: "A" + case .optionB: "B" + } + } +} + +public enum CommentSort: String, CaseIterable, Equatable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} + +public struct VoteSummary: Equatable { + public var changeBadgeTitle: String + public var optionA: VoteOptionSummary + public var optionB: VoteOptionSummary + + public init( + changeBadgeTitle: String, + optionA: VoteOptionSummary, + optionB: VoteOptionSummary + ) { + self.changeBadgeTitle = changeBadgeTitle + self.optionA = optionA + self.optionB = optionB + } + + public static let mock = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), + optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) + ) +} + +public struct VoteOptionSummary: Equatable { + public var label: String + public var title: String + public var representative: String + public var percentage: Double + + public init( + label: String, + title: String, + representative: String, + percentage: Double + ) { + self.label = label + self.title = title + self.representative = representative + self.percentage = percentage + } +} + +public enum CommentOption: Equatable { + case a + case b + + public var label: String { + switch self { + case .a: "A" + case .b: "B" + } + } +} + +public struct CommentItem: Equatable, Identifiable { + public let id: UUID + public var author: String + public var timeAgo: String + public var option: CommentOption + public var content: String + public var replyCount: Int + public var likeCount: Int + public var isLiked: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + author: String, + timeAgo: String, + option: CommentOption, + content: String, + replyCount: Int, + likeCount: Int, + isLiked: Bool = false, + createdOrder: Int + ) { + self.id = id + self.author = author + self.timeAgo = timeAgo + self.option = option + self.content = content + self.replyCount = replyCount + self.likeCount = likeCount + self.isLiked = isLiked + self.createdOrder = createdOrder + } + + public static let mocks: [CommentItem] = [ + .init( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), + author: "사유하는 사용자", + timeAgo: "2분 전", + option: .a, + content: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요. 네덜란드 사례를 보면 우려가 현실이 되고 있죠. 제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요.", + replyCount: 23, + likeCount: 1340, + createdOrder: 3 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), + author: "논쟁을 즐기는 사람", + timeAgo: "8분 전", + option: .b, + content: "맥락을 바꾸면 같은 사물도 전혀 다른 의미를 갖게 됩니다. 결국 예술은 물건의 가격보다 해석의 층위로 결정되는 것 같아요.", + replyCount: 8, + likeCount: 692, + createdOrder: 2 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000003") ?? UUID(), + author: "깊게 읽는 독자", + timeAgo: "15분 전", + option: .a, + content: "브랜드가 붙었다고 본질이 달라지는 건 아니라고 봅니다. 사용되는 기능과 재료가 같다면 사치재의 권위는 결국 합의된 환상에 가깝죠.", + replyCount: 4, + likeCount: 421, + createdOrder: 1 + ), + ] +} diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift new file mode 100644 index 00000000..f5070485 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -0,0 +1,403 @@ +// +// CommentView.swift +// Chat +// +// .pen `댓글화면` 기준 mock 댓글 UI. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem + +@ViewAction(for: CommentFeature.self) +public struct CommentView: View { + @Bindable public var store: StoreOf + @FocusState private var isCommentFocused: Bool + @State private var hasAnimatedVoteProgress = false + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + summarySection + filterSection + commentList + } + .padding(.bottom, 16) + } + .scrollDismissesKeyboard(.interactively) + inputBar + } + .background(Color.beige200.ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isCommentFocused = false + } + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { + withAnimation(.easeOut(duration: 0.75).delay(0.15)) { + hasAnimatedVoteProgress = true + } + } + } +} + +// MARK: - Navigation + +private extension CommentView { + var navigationBar: some View { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + Text(store.title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + .lineLimit(1) + + Spacer() + + Color.clear.frame(width: 24, height: 24) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .foregroundStyle(.neutral500) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Summary + +private extension CommentView { + var summarySection: some View { + VStack(spacing: 12) { + HStack { + Text(store.voteSummary.changeBadgeTitle) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.primary500) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + Spacer() + } + + HStack(alignment: .center, spacing: 12) { + voteSide(store.voteSummary.optionA, alignment: .leading) + voteProgress + voteSide(store.voteSummary.optionB, alignment: .trailing) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.beige50) + } + + func voteSide(_ option: VoteOptionSummary, alignment: HorizontalAlignment) -> some View { + VStack(alignment: alignment, spacing: 6) { + avatarLabel(option.representative) + Text(percentText(option.percentage)) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + } + .frame(width: 52, alignment: alignment == .leading ? .leading : .trailing) + } + + var voteProgress: some View { + GeometryReader { proxy in + let leftWidth = proxy.size.width * store.voteSummary.optionA.percentage + ZStack(alignment: .leading) { + Capsule() + .fill(.beige600) + .frame(height: 6) + Capsule() + .fill(.primary500) + .frame(width: hasAnimatedVoteProgress ? max(0, leftWidth) : 0, height: 6) + } + .frame(maxHeight: .infinity) + .animation(.easeOut(duration: 0.75), value: store.voteSummary.optionA.percentage) + } + .frame(height: 6) + } + + func avatarLabel(_ name: String) -> some View { + VStack(spacing: 4) { + Circle() + .fill(.beige600) + .frame(width: 40, height: 40) + .overlay { + Text(String(name.prefix(1))) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.primary500) + } + + Text(name) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + .lineLimit(1) + } + } +} + +// MARK: - Filters + +private extension CommentView { + var filterSection: some View { + VStack(spacing: 12) { + HStack(spacing: 8) { + ForEach(CommentFilter.allCases, id: \.self) { filter in + filterButton(filter) + } + Spacer() + } + + HStack(spacing: 0) { + sortButton(.popular) + sortButton(.latest) + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 12) + } + + func filterButton(_ filter: CommentFilter) -> some View { + let isSelected = store.selectedFilter == filter + return Button { + send(.filterTapped(filter)) + } label: { + Text(filter.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .neutral300) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isSelected ? Color.primary50 : Color.clear, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } + + func sortButton(_ sort: CommentSort) -> some View { + let isSelected = store.selectedSort == sort + return Button { + send(.sortTapped(sort)) + } label: { + Text(sort.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(isSelected ? .beige50 : .primary500) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(isSelected ? Color.primary400 : Color.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? Color.primary400 : Color.primary50, lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Comment List + +private extension CommentView { + var commentList: some View { + VStack(spacing: 12) { + ForEach(store.filteredComments) { comment in + commentCard(comment) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + + func commentCard(_ comment: CommentItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + commentHeader(comment) + + Text(comment.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + + commentActions(comment) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + + func commentHeader(_ comment: CommentItem) -> some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(.beige600) + .frame(width: 36, height: 36) + .overlay { + Text(String(comment.author.prefix(1))) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(comment.author) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .lineLimit(1) + + Text(comment.timeAgo) + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral300) + } + + optionBadge(comment.option) + } + + Spacer() + + Button { send(.moreTapped(comment.id)) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .foregroundStyle(.neutral300) + } + } + + func optionBadge(_ option: CommentOption) -> some View { + let summary = option == .a ? store.voteSummary.optionA : store.voteSummary.optionB + return Text("\(option.label) \(summary.title)") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + func commentActions(_ comment: CommentItem) -> some View { + HStack(spacing: 12) { + Text("더보기") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + + Spacer() + + Button { send(.replyTapped(comment.id)) } label: { + actionLabel(systemName: "message", text: "\(comment.replyCount)") + } + .buttonStyle(.plain) + + Button { send(.likeTapped(comment.id)) } label: { + actionLabel( + systemName: comment.isLiked ? "heart.fill" : "heart", + text: formattedCount(comment.likeCount) + ) + } + .buttonStyle(.plain) + } + .foregroundStyle(.neutral300) + } + + func actionLabel(systemName: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .frame(width: 16, height: 16) + Text(text) + .pretendardFont(family: .Medium, size: 12) + } + } +} + +// MARK: - Input + +private extension CommentView { + var inputBar: some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + TextField("댓글을 입력해주세요", text: $store.commentText, axis: .vertical) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineLimit(1 ... 3) + .focused($isCommentFocused) + + Text("\(store.commentText.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral400) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.beige50) + + Button { send(.sendTapped) } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + .frame(width: 36, height: 36) + .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!store.isSendEnabled) + } + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 24) + .frame(height: 128) + .background(.beige400) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige800) + .frame(height: 1) + } + } +} + +// MARK: - Format + +private extension CommentView { + func percentText(_ percentage: Double) -> String { + "\(String(format: "%.1f", percentage * 100))%" + } + + func formattedCount(_ count: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: count)) ?? "\(count)" + } +} + +#Preview { + CommentView( + store: Store(initialState: CommentFeature.State()) { + CommentFeature() + } + ) +} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index ddcc7394..fa5066cc 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -71,8 +71,13 @@ extension ChatCoordinator { case .routeAction(_, action: .preVote(.delegate(.dismiss))): return .send(.delegate(.dismiss)) - case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, _)))): - state.routes.push(.chatRoom(.init(battleId: battleId))) + case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, voteMode, _)))): + switch voteMode { + case .pre: + state.routes.push(.chatRoom(.init(battleId: battleId))) + case .post: + state.routes.push(.comment(.init(battleId: battleId))) + } return .none case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): @@ -82,6 +87,9 @@ extension ChatCoordinator { state.routes.push(.preVote(.init(battleId: battleId, voteMode: .post))) return .none + case .routeAction(_, action: .comment(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -118,6 +126,7 @@ extension ChatCoordinator { public enum ChatScreen { case preVote(PreVoteFeature) case chatRoom(ChatRoomFeature) + case comment(CommentFeature) } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift index e724ee28..6e97167d 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -26,6 +26,9 @@ public struct ChatCoordinatorView: View { case let .chatRoom(chatRoomStore): ChatRoomView(store: chatRoomStore) .toolbar(.hidden, for: .tabBar) + case let .comment(commentStore): + CommentView(store: commentStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index cc986c69..c7c0e363 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit import ComposableArchitecture import DomainInterface @@ -57,12 +58,16 @@ public struct PreVoteFeature { /// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩. public struct ShareItem: Equatable, Identifiable { public let id: UUID - public let items: [String] + public let items: [Any] - public init(id: UUID = UUID(), items: [String]) { + public init(id: UUID = UUID(), items: [Any]) { self.id = id self.items = items } + + public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { + lhs.id == rhs.id + } } public enum Action: ViewAction, BindableAction { @@ -84,6 +89,7 @@ public struct PreVoteFeature { public enum AsyncAction: Equatable { case fetchBattleDetail + case prepareShare(title: String, url: String, imageURL: String?) case submitPreVote(battleId: Int, optionId: Int) case submitPostVote(battleId: Int, optionId: Int) } @@ -91,12 +97,13 @@ public struct PreVoteFeature { public enum InnerAction: Equatable { case battleDetailResponse(Result) case preVoteResponse(Result) + case sharePrepared(ShareItem) case postVoteResponse(Result) } public enum DelegateAction: Equatable { case dismiss - case voteSubmitted(battleId: Int, result: PreVoteResult) + case voteSubmitted(battleId: Int, voteMode: VoteMode, result: PreVoteResult) } nonisolated enum CancelID: Hashable { @@ -150,8 +157,8 @@ extension PreVoteFeature { let title = state.battleDetail?.battleInfo.title ?? state.battle?.titleLine1 ?? "" let url = state.battleDetail?.shareUrl ?? "https://picke.store/battles/\(state.battleId)" - state.shareItem = ShareItem(items: [title, url]) - return .none + let imageURL = state.battleDetail?.battleInfo.thumbnailUrl ?? state.battle?.backgroundImageURL + return .send(.async(.prepareShare(title: title, url: url, imageURL: imageURL))) case let .optionTapped(optionId): state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId @@ -164,7 +171,7 @@ extension PreVoteFeature { case .pre: return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) case .post: - return .none + return .send(.async(.submitPostVote(battleId: state.battleId, optionId: optionId))) } } } @@ -186,6 +193,21 @@ extension PreVoteFeature { } .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) + case let .prepareShare(title, url, imageURL): + return .run { send in + var items: [Any] = [title, url] + + if let imageURL, + let remoteURL = URL(string: imageURL), + let (data, _) = try? await URLSession.shared.data(from: remoteURL), + let image = UIImage(data: data) + { + items.append(image) + } + + await send(.inner(.sharePrepared(ShareItem(items: items)))) + } + case let .submitPreVote(battleId, optionId): return .run { [repository = battleRepository] send in let result = await Result { @@ -228,20 +250,24 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): - return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: voteResult))) + return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .pre, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") - return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) + return .none } + case let .sharePrepared(item): + state.shareItem = item + return .none + case let .postVoteResponse(result): state.isSubmitting = false switch result { case let .success(voteResult): - return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: voteResult))) + return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)") - return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) + return .none } } } @@ -283,7 +309,7 @@ extension PreVoteFeature { ) -> Effect { switch action { case .dismiss, .voteSubmitted: - .none + return .none } } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index e9f438fe..a71aa913 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -130,7 +130,6 @@ struct CustomConfirmationPopup: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.horizontal, 20) - .padding(.vertical, 20) HStack(spacing: 10) { Button(action: onCancel) { @@ -156,13 +155,13 @@ struct CustomConfirmationPopup: View { .buttonStyle(.plain) } } + .padding(.top, 20) .frame(width: 313) .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) .stroke(.primary500, lineWidth: 1.5) ) - .opacity(0.9) .onTapGesture {} } } From 68a3e85990c3a05cd3c059bed5b53fd017a74b1b Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 23:02:24 +0900 Subject: [PATCH 06/42] =?UTF-8?q?feat:=20=ED=88=AC=ED=91=9C=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20API(/vote-stats)=20=EC=97=B0=EB=8F=99=20+=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=ED=99=94=EB=A9=B4=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=8B=A4=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity/Home/BattleVoteStats + BattleVoteStatsOption 신설 - Data Model: BattleVoteStatsDataDTO + BattleVoteStatsOptionDTO + 매퍼 (updatedAt 은 ISO8601 fractional seconds 파서) - BattleAPI / BattleService.voteStats(battleId:) (GET, no body) - BattleInterface.fetchVoteStats + Default/Repository 구현 - CommentFeature - AsyncAction.fetchVoteStats + InnerAction.voteStatsResponse(Result) - View.onAppear 에서 fetch 트리거 (isLoadingStats 가드) - 성공 시 응답 옵션 0/1 을 VoteSummary.optionA/B 에 매핑, ratio·label·stance 반영 - 실패 시 fallback (기존 voteSummary) 유지 + 로그 - CommentView.onAppear 에서 send(.onAppear) 호출 --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/BattleVoteStatsDataDTO.swift | 24 +++++ .../Mapper/BattleVoteStatsDataDTO+.swift | 40 ++++++++ .../Sources/Battle/BattleRepositoryImpl.swift | 14 +++ .../Sources/Battle/BattleService.swift | 7 ++ .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 4 + .../Entity/Sources/Home/BattleVoteStats.swift | 55 +++++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 86 +++++++++++++++-- .../Comment/Reducer/CommentFeature.swift | 93 ++++++++++++++++++- .../Sources/Comment/View/CommentView.swift | 1 + 11 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index e46472cc..3240de41 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -10,6 +10,7 @@ public enum BattleAPI { case preVote(battleId: Int) case postVote(battleId: Int) case scenario(battleId: Int) + case voteStats(battleId: Int) public var description: String { switch self { @@ -21,6 +22,8 @@ public enum BattleAPI { "\(battleId)/votes/post" case let .scenario(battleId): "\(battleId)/scenario" + case let .voteStats(battleId): + "\(battleId)/vote-stats" } } } diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift new file mode 100644 index 00000000..fc439ca2 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift @@ -0,0 +1,24 @@ +// +// BattleVoteStatsDataDTO.swift +// Model +// + +import Foundation + +public struct BattleVoteStatsDataDTO: Decodable { + public let options: [BattleVoteStatsOptionDTO] + public let totalCount: Int + public let updatedAt: String? +} + +public struct BattleVoteStatsOptionDTO: Decodable { + public let optionId: Int + public let label: String? + public let title: String + public let isCorrect: Bool? + public let voteCount: Int + public let ratio: Double + public let stance: String? +} + +public typealias BattleVoteStatsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift new file mode 100644 index 00000000..f9be761f --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift @@ -0,0 +1,40 @@ +// +// BattleVoteStatsDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleVoteStatsDataDTO { + func toDomain() -> BattleVoteStats { + BattleVoteStats( + options: options.map { $0.toDomain() }, + totalCount: totalCount, + updatedAt: updatedAt.flatMap(Self.parseISO8601) + ) + } + + /// `2026-05-22T13:28:16.697Z` 형태의 ISO8601 (fractional seconds 포함) 파싱. + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension BattleVoteStatsOptionDTO { + func toDomain() -> BattleVoteStatsOption { + BattleVoteStatsOption( + optionId: optionId, + label: label, + title: title, + isCorrect: isCorrect ?? false, + voteCount: voteCount, + ratio: ratio, + stance: stance ?? "" + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 7d2d6b9a..ee242ddb 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -52,6 +52,20 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { + let dto: BattleVoteStatsResponseDTO = try await provider.request( + .voteStats(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "투표 통계 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty voteStats payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } + public func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { let dto: PreVoteResponseDTO = try await provider.request( .postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index eb657b4f..55d9a521 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -15,6 +15,7 @@ public enum BattleService { case preVote(battleId: Int, body: PreVoteRequest) case postVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) + case voteStats(battleId: Int) } extension BattleService: BaseTargetType { @@ -32,6 +33,8 @@ extension BattleService: BaseTargetType { BattleAPI.postVote(battleId: battleId).description case let .scenario(battleId): BattleAPI.scenario(battleId: battleId).description + case let .voteStats(battleId): + BattleAPI.voteStats(battleId: battleId).description } } @@ -47,6 +50,8 @@ extension BattleService: BaseTargetType { .post case .scenario: .get + case .voteStats: + .get } } @@ -60,6 +65,8 @@ extension BattleService: BaseTargetType { body.toDictionary case .scenario: nil + case .voteStats: + nil } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index ed654f39..8b2cfe31 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -12,6 +12,7 @@ public protocol BattleInterface: Sendable { func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult func fetchScenario(battleId: Int) async throws -> BattleScenario + func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 08e4a1c1..3ead6f35 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -36,6 +36,10 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { PreVoteResult(voteId: 0, status: .none) } + public func fetchVoteStats(battleId _: Int) async throws -> BattleVoteStats { + BattleVoteStats(options: [], totalCount: 0, updatedAt: nil) + } + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } diff --git a/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift new file mode 100644 index 00000000..bf951f3a --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift @@ -0,0 +1,55 @@ +// +// BattleVoteStats.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. +// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. +// + +import Foundation + +public struct BattleVoteStats: Equatable { + public let options: [BattleVoteStatsOption] + public let totalCount: Int + public let updatedAt: Date? + + public init( + options: [BattleVoteStatsOption], + totalCount: Int, + updatedAt: Date? + ) { + self.options = options + self.totalCount = totalCount + self.updatedAt = updatedAt + } +} + +public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String? + public let title: String + public let isCorrect: Bool + public let voteCount: Int + public let ratio: Double + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String?, + title: String, + isCorrect: Bool, + voteCount: Int, + ratio: Double, + stance: String + ) { + self.optionId = optionId + self.label = label + self.title = title + self.isCorrect = isCorrect + self.voteCount = voteCount + self.ratio = ratio + self.stance = stance + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index a2a4c74c..d2999c66 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -33,6 +33,10 @@ public struct ChatRoomFeature { /// 현재 재생 중인 시나리오 노드 id (없으면 startNodeId 폴백) public var currentNodeId: Int? + /// 현재 타임라인에서 화면에 노출된 노드들. nextNodeId/autoNextNodeId 를 따라 누적된다. + public var visibleNodeIds: [Int] = [] + /// 인터랙티브 노드 끝에 도달해 사용자의 입장 선택을 기다리는 상태. + public var isWaitingForNodeSelection: Bool = false /// 선택지 영역에서 사용자가 탭한 옵션 label public var selectedOptionLabel: String? @@ -47,7 +51,8 @@ public struct ChatRoomFeature { public var messages: [ChatMessage] { guard let scenario else { return bundle.messages } - return scenario.nodes.flatMap { node in + let nodes = visibleNodes(in: scenario) + return nodes.flatMap { node in node.scripts.map { script in ChatMessage( messageId: Self.scriptUUID(scriptId: script.scriptId), @@ -132,7 +137,8 @@ public struct ChatRoomFeature { } public var interactiveOptions: [ScenarioInteractiveOption] { - currentNode?.interactiveOptions ?? [] + guard isWaitingForNodeSelection else { return [] } + return currentNode?.interactiveOptions ?? [] } public var visibleOptions: [ScenarioInteractiveOption] { @@ -140,7 +146,7 @@ public struct ChatRoomFeature { } public var shouldShowOptions: Bool { - !visibleOptions.isEmpty + isWaitingForNodeSelection && !visibleOptions.isEmpty } public var isConfirmEnabled: Bool { selectedOptionLabel != nil } @@ -157,6 +163,23 @@ public struct ChatRoomFeature { fileprivate static func listenedKey(battleId: Int) -> String { "picke.chatRoom.hasFinishedListening.\(battleId)" } + + public func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] { + let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? scenario.startNodeId] : visibleNodeIds + return ids.compactMap { id in + scenario.nodes.first { $0.nodeId == id } + } + } + + public func nodeStartTime(for nodeId: Int) -> TimeInterval { + guard let node = scenario?.nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } + return TimeInterval((node.scripts.map(\.startTimeMs).min() ?? 0)) / 1000 + } + + public func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let start = TimeInterval((node.scripts.map(\.startTimeMs).min() ?? 0)) / 1000 + return start + TimeInterval(node.audioDuration) + } } public enum Action: ViewAction, BindableAction { @@ -265,6 +288,10 @@ extension ChatRoomFeature { case .refreshTapped: state.currentTime = 0 state.isPlaying = false + state.currentNodeId = state.scenario?.startNodeId + state.visibleNodeIds = state.scenario.map { [$0.startNodeId] } ?? [] + state.isWaitingForNodeSelection = false + state.selectedOptionLabel = nil return .run { [player = audioPlayer] _ in await player.pause() await player.seek(to: 0) @@ -310,12 +337,17 @@ extension ChatRoomFeature { let option = state.visibleOptions.first(where: { $0.label == label }) else { return .none } state.currentNodeId = option.nextNodeId + if !state.visibleNodeIds.contains(option.nextNodeId) { + state.visibleNodeIds.append(option.nextNodeId) + } state.selectedOptionLabel = nil - state.currentTime = 0 - state.isPlaying = false + state.isWaitingForNodeSelection = false + let targetTime = state.nodeStartTime(for: option.nextNodeId) + state.currentTime = targetTime + state.isPlaying = true return .run { [player = audioPlayer] _ in - await player.pause() - await player.seek(to: 0) + await player.seek(to: targetTime) + await player.play() } } } @@ -367,6 +399,11 @@ extension ChatRoomFeature { if state.currentNodeId == nil { state.currentNodeId = scenario.startNodeId } + if state.visibleNodeIds.isEmpty { + state.visibleNodeIds = [scenario.startNodeId] + } + state.isWaitingForNodeSelection = false + state.selectedOptionLabel = nil if let urlString = scenario.audios[scenario.recommendedPathKey.rawValue] ?? scenario.audios.values.first, let url = URL(string: urlString) @@ -381,6 +418,9 @@ extension ChatRoomFeature { case let .playerTimeUpdated(time): state.currentTime = time + if let effect = advanceNodeIfNeeded(state: &state, time: time) { + return effect + } if state.totalDuration > 0, time >= state.totalDuration - 0.5 { @@ -402,6 +442,38 @@ extension ChatRoomFeature { } } + private func advanceNodeIfNeeded( + state: inout State, + time: TimeInterval + ) -> Effect? { + guard let scenario = state.scenario, + let currentNode = state.currentNode + else { return nil } + + let nodeEndTime = state.nodeEndTime(for: currentNode) + guard time >= nodeEndTime - 0.25 else { return nil } + + if !currentNode.interactiveOptions.isEmpty { + guard !state.isWaitingForNodeSelection else { return nil } + state.isWaitingForNodeSelection = true + state.isPlaying = false + return .run { [player = audioPlayer] _ in + await player.pause() + } + } + + guard let nextNodeId = currentNode.autoNextNodeId, + scenario.nodes.contains(where: { $0.nodeId == nextNodeId }), + state.currentNodeId != nextNodeId + else { return nil } + + state.currentNodeId = nextNodeId + if !state.visibleNodeIds.contains(nextNodeId) { + state.visibleNodeIds.append(nextNodeId) + } + return .none + } + private func handleScopeAction(state: inout State, action: ScopeAction) -> Effect { switch action { case let .customAlert(alertAction): diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 9bd70818..d42c1b52 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -2,12 +2,16 @@ // CommentFeature.swift // Chat // -// 댓글 화면 mock 상태. 서버 API 가 붙기 전까지 화면/상호작용 형태를 고정한다. +// 댓글 화면. vote-stats API 로 상단 통계만 실데이터 사용, +// 댓글 리스트는 아직 mock. // import Foundation import ComposableArchitecture +import DomainInterface +import Entity +import LogMacro @Reducer public struct CommentFeature { @@ -18,6 +22,7 @@ public struct CommentFeature { public var battleId: Int public var title: String public var voteSummary: VoteSummary + public var isLoadingStats: Bool = false public var selectedFilter: CommentFilter = .all public var selectedSort: CommentSort = .popular public var comments: [CommentItem] @@ -54,11 +59,14 @@ public struct CommentFeature { public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) + case async(AsyncAction) + case inner(InnerAction) case delegate(DelegateAction) } @CasePathable public enum View { + case onAppear case backButtonTapped case shareTapped case filterTapped(CommentFilter) @@ -69,10 +77,24 @@ public struct CommentFeature { case sendTapped } + public enum AsyncAction: Equatable { + case fetchVoteStats + } + + public enum InnerAction: Equatable { + case voteStatsResponse(Result) + } + public enum DelegateAction: Equatable { case dismiss } + nonisolated enum CancelID: Hashable { + case fetchVoteStats + } + + @Dependency(\.battleRepository) private var battleRepository + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -89,6 +111,12 @@ public struct CommentFeature { case let .view(viewAction): return handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: return .none } @@ -102,6 +130,10 @@ extension CommentFeature { action: View ) -> Effect { switch action { + case .onAppear: + guard !state.isLoadingStats else { return .none } + return .send(.async(.fetchVoteStats)) + case .backButtonTapped: return .send(.delegate(.dismiss)) @@ -147,6 +179,65 @@ extension CommentFeature { return .none } } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchVoteStats: + state.isLoadingStats = true + let battleId = state.battleId + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchVoteStats(battleId: battleId) + } + .mapError(AuthError.from) + return await send(.inner(.voteStatsResponse(result))) + } + .cancellable(id: CancelID.fetchVoteStats, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .voteStatsResponse(result): + state.isLoadingStats = false + switch result { + case let .success(stats): + state.voteSummary = makeSummary(from: stats, fallback: state.voteSummary) + case let .failure(error): + Log.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)") + } + return .none + } + } + + /// API 응답(BattleVoteStats) 을 화면 모델 VoteSummary 로 매핑. + /// 옵션이 2개 이상이라 가정 — 부족하거나 매핑 실패 시 fallback 유지. + private func makeSummary(from stats: BattleVoteStats, fallback: VoteSummary) -> VoteSummary { + guard stats.options.count >= 2 else { return fallback } + let a = stats.options[0] + let b = stats.options[1] + return VoteSummary( + changeBadgeTitle: fallback.changeBadgeTitle, + optionA: VoteOptionSummary( + label: a.label ?? "A", + title: a.title, + representative: a.stance.isEmpty ? fallback.optionA.representative : a.stance, + percentage: a.ratio + ), + optionB: VoteOptionSummary( + label: b.label ?? "B", + title: b.title, + representative: b.stance.isEmpty ? fallback.optionB.representative : b.stance, + percentage: b.ratio + ) + ) + } } public enum CommentFilter: String, CaseIterable, Equatable { diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index f5070485..95723942 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -43,6 +43,7 @@ public struct CommentView: View { .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { + send(.onAppear) withAnimation(.easeOut(duration: 0.75).delay(0.15)) { hasAnimatedVoteProgress = true } From 54044e633c7e3792bfaf2dab2e63a0edf51560f3 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 00:00:59 +0900 Subject: [PATCH 07/42] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8B=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A0=84=EC=9A=A9=20BattleError=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20vote-stats=20DTO=20null=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20/=20ratio=20=EB=8B=A8=EC=9C=84=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity/Error/BattleError 신설 (Attendance_iOS 의 AttendanceError 패턴) - networkError / decodingError / noData / unauthorized / backendError / serverError / unknown + LocalizedError 텍스트 + BattleError.from(_:) 컨버터 - BattleRepositoryImpl: 모든 throw AuthError.backendError → throw BattleError.backendError - PreVoteFeature / ChatRoomFeature / CommentFeature - Result<*, AuthError> → Result<*, BattleError> - mapError(AuthError.from) → mapError(BattleError.from) - BattleVoteStatsOptionDTO - title 을 Optional 로 (응답에서 누락되는 케이스 디코딩 실패 해결) - imageUrl 필드 추가 - BattleVoteStatsOption Entity 에 imageUrl 추가 - 매퍼: ratio 가 1 보다 크면(서버가 퍼센트로 내려준 경우) 100 으로 나눠 비율로 정규화 --- .../Battle/DTO/BattleVoteStatsDataDTO.swift | 3 +- .../Mapper/BattleVoteStatsDataDTO+.swift | 7 +- .../Sources/Battle/BattleRepositoryImpl.swift | 10 +-- .../Entity/Sources/Error/BattleError.swift | 82 +++++++++++++++++++ .../Entity/Sources/Home/BattleVoteStats.swift | 5 +- .../ChatRoom/Reducer/ChatRoomFeature.swift | 4 +- .../Comment/Reducer/CommentFeature.swift | 4 +- .../Sources/Vote/Reducer/PreVoteFeature.swift | 12 +-- 8 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Error/BattleError.swift diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift index fc439ca2..bc7d0369 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift @@ -14,11 +14,12 @@ public struct BattleVoteStatsDataDTO: Decodable { public struct BattleVoteStatsOptionDTO: Decodable { public let optionId: Int public let label: String? - public let title: String + public let title: String? public let isCorrect: Bool? public let voteCount: Int public let ratio: Double public let stance: String? + public let imageUrl: String? } public typealias BattleVoteStatsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift index f9be761f..fc3f0728 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift @@ -30,11 +30,12 @@ public extension BattleVoteStatsOptionDTO { BattleVoteStatsOption( optionId: optionId, label: label, - title: title, + title: title ?? "", isCorrect: isCorrect ?? false, voteCount: voteCount, - ratio: ratio, - stance: stance ?? "" + ratio: ratio > 1 ? ratio / 100.0 : ratio, + stance: stance ?? "", + imageUrl: imageUrl ) } } diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index ee242ddb..fe2f282a 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -32,7 +32,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "배틀 상세 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty battleDetail payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() @@ -46,7 +46,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "사전 투표 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty preVote payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() @@ -60,7 +60,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "투표 통계 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty voteStats payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() @@ -74,7 +74,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "최종 투표 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty postVote payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() @@ -88,7 +88,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "시나리오 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty scenario payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() diff --git a/Projects/Domain/Entity/Sources/Error/BattleError.swift b/Projects/Domain/Entity/Sources/Error/BattleError.swift new file mode 100644 index 00000000..23706560 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/BattleError.swift @@ -0,0 +1,82 @@ +// +// BattleError.swift +// Entity +// +// 배틀 도메인 (배틀 상세 / 시나리오 / 사전·최종 투표 / 투표 통계) 의 표준 에러. +// + +import Foundation + +public enum BattleError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "배틀 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "배틀 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } + + public var failureReason: String? { + switch self { + case .networkError: + "네트워크 연결을 확인해주세요" + case .decodingError: + "서버 응답 데이터가 올바르지 않습니다" + case .noData: + "조회된 배틀 정보가 없습니다" + case .unauthorized: + "로그인이 필요합니다" + case .backendError: + "백엔드 응답에서 비즈니스 오류가 보고되었습니다" + case .serverError: + "잠시 후 다시 시도해주세요" + case .unknown: + "예상치 못한 오류가 발생했습니다" + } + } + + public var recoverySuggestion: String? { + switch self { + case .networkError: + "인터넷 연결을 확인하고 다시 시도해주세요" + case .decodingError: + "앱을 다시 시작해보세요" + case .noData: + "새로고침하거나 관리자에게 문의하세요" + case .unauthorized: + "다시 로그인해주세요" + case .backendError: + "다른 옵션을 선택하거나 잠시 후 다시 시도해주세요" + case .serverError: + "잠시 후 다시 시도하거나 고객센터에 문의하세요" + case .unknown: + "문제가 지속되면 고객센터에 문의해주세요" + } + } +} + +public extension BattleError { + static func from(_ error: Error) -> BattleError { + if let battleError = error as? BattleError { return battleError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift index bf951f3a..71d5fe9b 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift +++ b/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift @@ -32,6 +32,7 @@ public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { public let voteCount: Int public let ratio: Double public let stance: String + public let imageUrl: String? public var id: Int { optionId } @@ -42,7 +43,8 @@ public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { isCorrect: Bool, voteCount: Int, ratio: Double, - stance: String + stance: String, + imageUrl: String? ) { self.optionId = optionId self.label = label @@ -51,5 +53,6 @@ public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { self.voteCount = voteCount self.ratio = ratio self.stance = stance + self.imageUrl = imageUrl } } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index d2999c66..dc84a4d7 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -212,7 +212,7 @@ public struct ChatRoomFeature { } public enum InnerAction: Equatable { - case scenarioResponse(Result) + case scenarioResponse(Result) case playerTimeUpdated(TimeInterval) case playerDurationUpdated(TimeInterval) } @@ -361,7 +361,7 @@ extension ChatRoomFeature { let result = await Result { try await repository.fetchScenario(battleId: battleId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.scenarioResponse(result))) } .cancellable(id: CancelID.fetchScenario, cancelInFlight: true) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index d42c1b52..5be35edb 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -82,7 +82,7 @@ public struct CommentFeature { } public enum InnerAction: Equatable { - case voteStatsResponse(Result) + case voteStatsResponse(Result) } public enum DelegateAction: Equatable { @@ -192,7 +192,7 @@ extension CommentFeature { let result = await Result { try await repository.fetchVoteStats(battleId: battleId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.voteStatsResponse(result))) } .cancellable(id: CancelID.fetchVoteStats, cancelInFlight: true) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index c7c0e363..431f990a 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -95,10 +95,10 @@ public struct PreVoteFeature { } public enum InnerAction: Equatable { - case battleDetailResponse(Result) - case preVoteResponse(Result) + case battleDetailResponse(Result) + case preVoteResponse(Result) case sharePrepared(ShareItem) - case postVoteResponse(Result) + case postVoteResponse(Result) } public enum DelegateAction: Equatable { @@ -188,7 +188,7 @@ extension PreVoteFeature { let result = await Result { try await repository.fetchBattle(battleId: battleId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.battleDetailResponse(result))) } .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) @@ -213,7 +213,7 @@ extension PreVoteFeature { let result = await Result { try await repository.submitPreVote(battleId: battleId, optionId: optionId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.preVoteResponse(result))) } .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) @@ -223,7 +223,7 @@ extension PreVoteFeature { let result = await Result { try await repository.submitPostVote(battleId: battleId, optionId: optionId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.postVoteResponse(result))) } .cancellable(id: CancelID.submitPostVote, cancelInFlight: true) From 0081bc5909587843fafcff5f41023439d2a2f25c Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 00:14:22 +0900 Subject: [PATCH 08/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80(perspectives)?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99=20+=20CommentFeature=20=EC=8B=A4?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20+?= =?UTF-8?q?=20=ED=95=84=ED=84=B0/=EC=A0=95=EB=A0=AC=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity/Home/BattlePerspective + Page + User + Option + BattlePerspectiveSort 신설 - Data Model: BattlePerspectivePageDataDTO / 매퍼 (ISO8601 fractional 파서, null fallback) - BattleAPI / BattleService.perspectives(battleId:, cursor:, size:, optionLabel:, sort:) (GET + query) - BattleInterface.fetchPerspectives + Default/Repository 구현 - CommentFeature - State.isLoadingComments / nextCursor / hasNext / comments - AsyncAction.fetchPerspectives(reset:) + InnerAction.perspectivesResponse(Result, reset:) - onAppear 에서 voteStats + perspectives 병렬 호출 - filterTapped / sortTapped 시 reset 페치 (서버에 optionLabel / sort 쿼리) - 응답 BattlePerspective 를 CommentItem 으로 매핑 (deterministic UUID, relativeTime) - CommentFilter.queryLabel / CommentSort.perspectiveSort computed 로 enum → 쿼리값 매핑 --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/BattlePerspectiveDataDTO.swift | 40 +++++++ .../Mapper/BattlePerspectiveDataDTO+.swift | 63 +++++++++++ .../Sources/Battle/BattleRepositoryImpl.swift | 26 +++++ .../Sources/Battle/BattleService.swift | 22 +++- .../Sources/Battle/BattleInterface.swift | 7 ++ .../Battle/DefaultBattleRepositoryImpl.swift | 10 ++ .../Sources/Home/BattlePerspective.swift | 106 ++++++++++++++++++ .../Comment/Reducer/CommentFeature.swift | 104 +++++++++++++++-- 9 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Home/BattlePerspective.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 3240de41..dbfdfc24 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -11,6 +11,7 @@ public enum BattleAPI { case postVote(battleId: Int) case scenario(battleId: Int) case voteStats(battleId: Int) + case perspectives(battleId: Int) public var description: String { switch self { @@ -24,6 +25,8 @@ public enum BattleAPI { "\(battleId)/scenario" case let .voteStats(battleId): "\(battleId)/vote-stats" + case let .perspectives(battleId): + "\(battleId)/perspectives" } } } diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift new file mode 100644 index 00000000..cea175ce --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift @@ -0,0 +1,40 @@ +// +// BattlePerspectiveDataDTO.swift +// Model +// + +import Foundation + +public struct BattlePerspectivePageDataDTO: Decodable { + public let items: [BattlePerspectiveDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct BattlePerspectiveDTO: Decodable { + public let perspectiveId: Int + public let user: BattlePerspectiveUserDTO + public let option: BattlePerspectiveOptionDTO + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool? + public let isMyPerspective: Bool? + public let createdAt: String? +} + +public struct BattlePerspectiveUserDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +public struct BattlePerspectiveOptionDTO: Decodable { + public let optionId: Int + public let label: String? + public let title: String? + public let stance: String? +} + +public typealias BattlePerspectivePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift new file mode 100644 index 00000000..45e644c7 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift @@ -0,0 +1,63 @@ +// +// BattlePerspectiveDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattlePerspectivePageDataDTO { + func toDomain() -> BattlePerspectivePage { + BattlePerspectivePage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension BattlePerspectiveDTO { + func toDomain() -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: user.toDomain(), + option: option.toDomain(), + content: content, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked ?? false, + isMyPerspective: isMyPerspective ?? false, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension BattlePerspectiveUserDTO { + func toDomain() -> BattlePerspectiveUser { + BattlePerspectiveUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension BattlePerspectiveOptionDTO { + func toDomain() -> BattlePerspectiveOption { + BattlePerspectiveOption( + optionId: optionId, + label: label, + title: title ?? "", + stance: stance ?? "" + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index fe2f282a..ff4e5ac9 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -80,6 +80,32 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + public func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionLabel: String?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + let dto: BattlePerspectivePageResponseDTO = try await provider.request( + .perspectives( + battleId: battleId, + cursor: cursor, + size: size, + optionLabel: optionLabel, + sort: sort?.queryValue + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 목록 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty perspectives payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + public func fetchScenario(battleId: Int) async throws -> BattleScenario { let dto: BattleScenarioResponseDTO = try await provider.request( .scenario(battleId: battleId) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 55d9a521..478b6f7e 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -16,6 +16,7 @@ public enum BattleService { case postVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) case voteStats(battleId: Int) + case perspectives(battleId: Int, cursor: String?, size: Int?, optionLabel: String?, sort: String?) } extension BattleService: BaseTargetType { @@ -35,6 +36,8 @@ extension BattleService: BaseTargetType { BattleAPI.scenario(battleId: battleId).description case let .voteStats(battleId): BattleAPI.voteStats(battleId: battleId).description + case let .perspectives(battleId, _, _, _, _): + BattleAPI.perspectives(battleId: battleId).description } } @@ -52,21 +55,30 @@ extension BattleService: BaseTargetType { .get case .voteStats: .get + case .perspectives: + .get } } public var parameters: [String: Any]? { switch self { case .detail: - nil + return nil case let .preVote(_, body): - body.toDictionary + return body.toDictionary case let .postVote(_, body): - body.toDictionary + return body.toDictionary case .scenario: - nil + return nil case .voteStats: - nil + return nil + case let .perspectives(_, cursor, size, optionLabel, sort): + var query: [String: Any] = [:] + if let cursor { query["cursor"] = cursor } + if let size { query["size"] = size } + if let optionLabel { query["optionLabel"] = optionLabel } + if let sort { query["sort"] = sort } + return query.isEmpty ? nil : query } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 8b2cfe31..d3644224 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -13,6 +13,13 @@ public protocol BattleInterface: Sendable { func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult func fetchScenario(battleId: Int) async throws -> BattleScenario func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats + func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionLabel: String?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 3ead6f35..3ad26368 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -40,6 +40,16 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { BattleVoteStats(options: [], totalCount: 0, updatedAt: nil) } + public func fetchPerspectives( + battleId _: Int, + cursor _: String?, + size _: Int?, + optionLabel _: String?, + sort _: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + BattlePerspectivePage(items: [], nextCursor: nil, hasNext: false) + } + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } diff --git a/Projects/Domain/Entity/Sources/Home/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/BattlePerspective.swift new file mode 100644 index 00000000..c611eb3e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattlePerspective.swift @@ -0,0 +1,106 @@ +// +// BattlePerspective.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. +// 댓글(=관점) 리스트 + 커서 페이지네이션. +// + +import Foundation + +public struct BattlePerspectivePage: Equatable { + public let items: [BattlePerspective] + public let nextCursor: String? + public let hasNext: Bool + + public init(items: [BattlePerspective], nextCursor: String?, hasNext: Bool) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct BattlePerspective: Equatable, Identifiable, Hashable { + public let perspectiveId: Int + public let user: BattlePerspectiveUser + public let option: BattlePerspectiveOption + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + public let isMyPerspective: Bool + public let createdAt: Date? + + public var id: Int { perspectiveId } + + public init( + perspectiveId: Int, + user: BattlePerspectiveUser, + option: BattlePerspectiveOption, + content: String, + likeCount: Int, + commentCount: Int, + isLiked: Bool, + isMyPerspective: Bool, + createdAt: Date? + ) { + self.perspectiveId = perspectiveId + self.user = user + self.option = option + self.content = content + self.likeCount = likeCount + self.commentCount = commentCount + self.isLiked = isLiked + self.isMyPerspective = isMyPerspective + self.createdAt = createdAt + } +} + +public struct BattlePerspectiveUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} + +public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let label: String? + public let title: String + public let stance: String + + public var id: Int { optionId } + + public init(optionId: Int, label: String?, title: String, stance: String) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + } +} + +public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var queryValue: String { rawValue } + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 5be35edb..1b627f30 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -23,6 +23,9 @@ public struct CommentFeature { public var title: String public var voteSummary: VoteSummary public var isLoadingStats: Bool = false + public var isLoadingComments: Bool = false + public var nextCursor: String? + public var hasNext: Bool = false public var selectedFilter: CommentFilter = .all public var selectedSort: CommentSort = .popular public var comments: [CommentItem] @@ -79,10 +82,12 @@ public struct CommentFeature { public enum AsyncAction: Equatable { case fetchVoteStats + case fetchPerspectives(reset: Bool) } public enum InnerAction: Equatable { case voteStatsResponse(Result) + case perspectivesResponse(Result, reset: Bool) } public enum DelegateAction: Equatable { @@ -91,6 +96,7 @@ public struct CommentFeature { nonisolated enum CancelID: Hashable { case fetchVoteStats + case fetchPerspectives } @Dependency(\.battleRepository) private var battleRepository @@ -131,8 +137,10 @@ extension CommentFeature { ) -> Effect { switch action { case .onAppear: - guard !state.isLoadingStats else { return .none } - return .send(.async(.fetchVoteStats)) + return .merge( + .send(.async(.fetchVoteStats)), + .send(.async(.fetchPerspectives(reset: true))) + ) case .backButtonTapped: return .send(.delegate(.dismiss)) @@ -142,17 +150,11 @@ extension CommentFeature { case let .filterTapped(filter): state.selectedFilter = filter - return .none + return .send(.async(.fetchPerspectives(reset: true))) case let .sortTapped(sort): state.selectedSort = sort - switch sort { - case .popular: - state.comments.sort { $0.likeCount > $1.likeCount } - case .latest: - state.comments.sort { $0.createdOrder > $1.createdOrder } - } - return .none + return .send(.async(.fetchPerspectives(reset: true))) case let .likeTapped(id): guard let index = state.comments.firstIndex(where: { $0.id == id }) else { return .none } @@ -196,6 +198,27 @@ extension CommentFeature { return await send(.inner(.voteStatsResponse(result))) } .cancellable(id: CancelID.fetchVoteStats, cancelInFlight: true) + + case let .fetchPerspectives(reset): + state.isLoadingComments = true + let battleId = state.battleId + let cursor = reset ? nil : state.nextCursor + let optionLabel = state.selectedFilter.queryLabel + let sort = state.selectedSort.perspectiveSort + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchPerspectives( + battleId: battleId, + cursor: cursor, + size: 20, + optionLabel: optionLabel, + sort: sort + ) + } + .mapError(BattleError.from) + return await send(.inner(.perspectivesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetchPerspectives, cancelInFlight: true) } } @@ -213,6 +236,21 @@ extension CommentFeature { Log.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)") } return .none + + case let .perspectivesResponse(result, reset): + state.isLoadingComments = false + switch result { + case let .success(page): + let mapped = page.items.enumerated().map { idx, item in + CommentItem(item: item, order: idx) + } + state.comments = reset ? mapped : state.comments + mapped + state.nextCursor = page.nextCursor + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)") + } + return .none } } @@ -252,6 +290,15 @@ public enum CommentFilter: String, CaseIterable, Equatable { case .optionB: "B" } } + + /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. + public var queryLabel: String? { + switch self { + case .all: nil + case .optionA: "A" + case .optionB: "B" + } + } } public enum CommentSort: String, CaseIterable, Equatable { @@ -264,6 +311,13 @@ public enum CommentSort: String, CaseIterable, Equatable { case .latest: "최신순" } } + + public var perspectiveSort: BattlePerspectiveSort { + switch self { + case .popular: .popular + case .latest: .latest + } + } } public struct VoteSummary: Equatable { @@ -352,6 +406,36 @@ public struct CommentItem: Equatable, Identifiable { self.createdOrder = createdOrder } + /// API 응답 BattlePerspective 를 화면 모델로 변환. + public init(item: BattlePerspective, order: Int) { + let optionFallback: CommentOption = item.option.label == "B" ? .b : .a + self.init( + id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + author: item.user.nickname, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: optionFallback, + content: item.content, + replyCount: item.commentCount, + likeCount: item.likeCount, + isLiked: item.isLiked, + createdOrder: order + ) + } + + private static func deterministicUUID(perspectiveId: Int) -> String { + let hex = String(format: "%012X", perspectiveId) + return "00000000-0000-0000-0000-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } + public static let mocks: [CommentItem] = [ .init( id: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), From 19a1e0936cf804dc573e6f5ad5748731f46b4e8a Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 00:32:53 +0900 Subject: [PATCH 09/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94/=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20API(/comments/{id}/likes)=20=EC=97=B0=EB=8F=99=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PieckeDomain.comment 추가 (api/v1/comments/) - Entity: CommentLikeResult / CommentError 신설 - Data: CommentLikeDataDTO + 매퍼 - CommentAPI / CommentService.like(POST) · unlike(DELETE) 추가 (헤더 only) - DomainInterface: CommentInterface (likeComment / unlikeComment) + DependencyKey + Default 구현 - Repository: CommentRepositoryImpl (MoyaProvider.authorized) - App DI: CommentRepositoryImpl as CommentInterface 등록 - CommentFeature - State.CommentItem.perspectiveId 필드 추가, BattlePerspective 매핑 시 ID 보존 - View.likeTapped 시 optimistic update + AsyncAction.toggleLike 디스패치 (현재 isLiked 기준 POST/DELETE 분기) - InnerAction.likeResponse 로 서버 응답(likeCount/isLiked) 반영, 실패 시 로그 - @Dependency(\.commentRepository) 주입 --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Data/API/Sources/Base/PieckeDomain.swift | 15 +- .../Data/API/Sources/Comment/CommentAPI.swift | 20 ++ .../Comment/DTO/CommentLikeDataDTO.swift | 14 + .../Comment/Mapper/CommentLikeDataDTO+.swift | 17 + .../Comment/CommentRepositoryImpl.swift | 50 +++ .../Sources/Comment/CommentService.swift | 48 +++ .../Sources/Comment/CommentInterface.swift | 45 +++ .../Entity/Sources/Error/CommentError.swift | 35 ++ .../Domain/Entity/Sources/Home/Comment.swift | 75 +++++ .../Sources/Home/CommentLikeResult.swift | 20 ++ .../Comment/Reducer/CommentFeature.swift | 81 ++++- .../Sources/Comment/View/CommentView.swift | 48 ++- .../Reducer/CommentReplyFeature.swift | 114 +++++++ .../CommentReply/View/CommentReplyView.swift | 306 ++++++++++++++++++ .../Coordinator/Reducer/ChatCoordinator.swift | 8 + .../View/ChatCoordinatorView.swift | 3 + .../CustomConfirmationPopupView.swift | 12 +- 18 files changed, 880 insertions(+), 32 deletions(-) create mode 100644 Projects/Data/API/Sources/Comment/CommentAPI.swift create mode 100644 Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Comment/CommentService.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Error/CommentError.swift create mode 100644 Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift create mode 100644 Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift create mode 100644 Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 37bb5fe0..ae5db93d 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -37,6 +37,7 @@ public final class AppDIManager: Sendable { .register { AuthRepositoryImpl() as AuthInterface } .register { HomeRepositoryImpl() as HomeInterface } .register { BattleRepositoryImpl() as BattleInterface } + .register { CommentRepositoryImpl() as CommentInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 5e095655..49d46e4b 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -15,25 +15,28 @@ public enum PieckeDomain { case home case poll case battle + case comment } extension PieckeDomain: DomainType { public var baseURLString: String { - return BaseAPI.base.apiDescription + BaseAPI.base.apiDescription } public var url: String { switch self { case .auth: - return "api/v1/auth/" + "api/v1/auth/" case .profile: - return"api/v1/me/" + "api/v1/me/" case .home: - return"api/v1/home" + "api/v1/home" case .poll: - return "api/v1/poll" + "api/v1/poll" case .battle: - return "api/v1/battles/" + "api/v1/battles/" + case .comment: + "api/v1/comments/" } } } diff --git a/Projects/Data/API/Sources/Comment/CommentAPI.swift b/Projects/Data/API/Sources/Comment/CommentAPI.swift new file mode 100644 index 00000000..7cb7d124 --- /dev/null +++ b/Projects/Data/API/Sources/Comment/CommentAPI.swift @@ -0,0 +1,20 @@ +// +// CommentAPI.swift +// API +// + +import Foundation + +public enum CommentAPI { + case like(commentId: Int) + case unlike(commentId: Int) + + public var description: String { + switch self { + case let .like(commentId): + "\(commentId)/likes" + case let .unlike(commentId): + "\(commentId)/likes" + } + } +} diff --git a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift new file mode 100644 index 00000000..311af7a9 --- /dev/null +++ b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift @@ -0,0 +1,14 @@ +// +// CommentLikeDataDTO.swift +// Model +// + +import Foundation + +public struct CommentLikeDataDTO: Decodable { + public let perspectiveId: Int + public let likeCount: Int + public let isLiked: Bool +} + +public typealias CommentLikeResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift new file mode 100644 index 00000000..01db98af --- /dev/null +++ b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift @@ -0,0 +1,17 @@ +// +// CommentLikeDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension CommentLikeDataDTO { + func toDomain() -> CommentLikeResult { + CommentLikeResult( + perspectiveId: perspectiveId, + likeCount: likeCount, + isLiked: isLiked + ) + } +} diff --git a/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift b/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift new file mode 100644 index 00000000..7d90eb01 --- /dev/null +++ b/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift @@ -0,0 +1,50 @@ +// +// CommentRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class CommentRepositoryImpl: CommentInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request(.like(commentId: commentId)) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 좋아요 응답이 비어 있습니다" + Log.error("[CommentRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request(.unlike(commentId: commentId)) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 좋아요 취소 응답이 비어 있습니다" + Log.error("[CommentRepositoryImpl] empty unlike payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Comment/CommentService.swift b/Projects/Data/Service/Sources/Comment/CommentService.swift new file mode 100644 index 00000000..35cb4b6b --- /dev/null +++ b/Projects/Data/Service/Sources/Comment/CommentService.swift @@ -0,0 +1,48 @@ +// +// CommentService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum CommentService { + case like(commentId: Int) + case unlike(commentId: Int) +} + +extension CommentService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .comment } + + public var urlPath: String { + switch self { + case let .like(commentId): + CommentAPI.like(commentId: commentId).description + case let .unlike(commentId): + CommentAPI.unlike(commentId: commentId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .like: + .post + case .unlike: + .delete + } + } + + public var parameters: [String: Any]? { nil } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift b/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift new file mode 100644 index 00000000..93c9bd09 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift @@ -0,0 +1,45 @@ +// +// CommentInterface.swift +// DomainInterface +// + +import Dependencies +import Entity +import Foundation +import WeaveDI + +public protocol CommentInterface: Sendable { + func likeComment(commentId: Int) async throws -> CommentLikeResult + func unlikeComment(commentId: Int) async throws -> CommentLikeResult +} + +public struct DefaultCommentRepositoryImpl: CommentInterface { + public init() {} + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: commentId, likeCount: 0, isLiked: true) + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: commentId, likeCount: 0, isLiked: false) + } +} + +public struct CommentRepositoryDependency: DependencyKey { + public static var liveValue: CommentInterface { + UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() + } + + public static var testValue: CommentInterface { + UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() + } + + public static var previewValue: CommentInterface = liveValue +} + +public extension DependencyValues { + var commentRepository: CommentInterface { + get { self[CommentRepositoryDependency.self] } + set { self[CommentRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Error/CommentError.swift b/Projects/Domain/Entity/Sources/Error/CommentError.swift new file mode 100644 index 00000000..d46c77b2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/CommentError.swift @@ -0,0 +1,35 @@ +// +// CommentError.swift +// Entity +// + +import Foundation + +public enum CommentError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): "네트워크 오류: \(message)" + case let .decodingError(message): "데이터 파싱 오류: \(message)" + case .noData: "댓글 데이터가 없습니다" + case .unauthorized: "권한이 없습니다" + case let .backendError(message): "댓글 처리 실패: \(message)" + case let .serverError(code): "서버 오류 (코드: \(code))" + case let .unknown(message): "알 수 없는 오류: \(message)" + } + } +} + +public extension CommentError { + static func from(_ error: Error) -> CommentError { + if let commentError = error as? CommentError { return commentError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment.swift index 069d5130..ac0b4406 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment.swift @@ -7,6 +7,18 @@ import Foundation +public enum CommentOption: Equatable { + case a + case b + + public var label: String { + switch self { + case .a: "A" + case .b: "B" + } + } +} + public struct CommentAuthor: Equatable, Hashable { public let name: String public let imageURL: String? @@ -47,6 +59,69 @@ public struct Comment: Equatable, Identifiable, Hashable { } } +public struct CommentReplyItem: Equatable, Identifiable { + public let id: UUID + public var author: String + public var timeAgo: String + public var option: CommentOption + public var content: String + public var likeCount: Int + public var isLiked: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + author: String, + timeAgo: String, + option: CommentOption, + content: String, + likeCount: Int, + isLiked: Bool = false, + createdOrder: Int + ) { + self.id = id + self.author = author + self.timeAgo = timeAgo + self.option = option + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.createdOrder = createdOrder + } + + public static func mocks(for option: CommentOption) -> [CommentReplyItem] { + [ + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), + author: "사색하는 사슴", + timeAgo: "2분 전", + option: option, + content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", + likeCount: 1340, + createdOrder: 3 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), + author: "논쟁하는 사자", + timeAgo: "2분 전", + option: option, + content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", + likeCount: 534, + createdOrder: 2 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), + author: "질문하는 독자", + timeAgo: "5분 전", + option: option == .a ? .b : .a, + content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", + likeCount: 219, + createdOrder: 1 + ), + ] + } +} + public enum CommentSortType: String, Equatable, Hashable, CaseIterable { case popular = "POPULAR" case latest = "LATEST" diff --git a/Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift new file mode 100644 index 00000000..84625565 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift @@ -0,0 +1,20 @@ +// +// CommentLikeResult.swift +// Entity +// +// `POST/DELETE /api/v1/comments/{commentId}/likes` 응답. +// + +import Foundation + +public struct CommentLikeResult: Equatable, Hashable { + public let perspectiveId: Int + public let likeCount: Int + public let isLiked: Bool + + public init(perspectiveId: Int, likeCount: Int, isLiked: Bool) { + self.perspectiveId = perspectiveId + self.likeCount = likeCount + self.isLiked = isLiked + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 1b627f30..61c77848 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -28,6 +28,7 @@ public struct CommentFeature { public var hasNext: Bool = false public var selectedFilter: CommentFilter = .all public var selectedSort: CommentSort = .popular + public var reportTargetCommentID: UUID? public var comments: [CommentItem] public var commentText: String = "" @@ -76,6 +77,9 @@ public struct CommentFeature { case sortTapped(CommentSort) case moreTapped(UUID) case replyTapped(UUID) + case reportButtonTapped(UUID) + case reportConfirmTapped(UUID) + case reportPopupDismissed case likeTapped(UUID) case sendTapped } @@ -83,23 +87,28 @@ public struct CommentFeature { public enum AsyncAction: Equatable { case fetchVoteStats case fetchPerspectives(reset: Bool) + case toggleLike(commentId: Int, currentlyLiked: Bool) } public enum InnerAction: Equatable { case voteStatsResponse(Result) case perspectivesResponse(Result, reset: Bool) + case likeResponse(Result) } public enum DelegateAction: Equatable { case dismiss + case openReply(CommentItem) } nonisolated enum CancelID: Hashable { case fetchVoteStats case fetchPerspectives + case toggleLike } @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.commentRepository) private var commentRepository public var body: some Reducer { BindingReducer() @@ -145,22 +154,45 @@ extension CommentFeature { case .backButtonTapped: return .send(.delegate(.dismiss)) - case .shareTapped, .moreTapped, .replyTapped: + case .shareTapped: + return .none + + case let .moreTapped(id), let .replyTapped(id): + guard let comment = state.comments.first(where: { $0.id == id }) else { return .none } + state.reportTargetCommentID = nil + return .send(.delegate(.openReply(comment))) + + case let .reportButtonTapped(id): + state.reportTargetCommentID = state.reportTargetCommentID == id ? nil : id + return .none + + case .reportPopupDismissed: + state.reportTargetCommentID = nil + return .none + + case let .reportConfirmTapped(id): + state.reportTargetCommentID = nil + Log.debug("[CommentFeature] report comment tapped: \(id)") return .none case let .filterTapped(filter): state.selectedFilter = filter + state.reportTargetCommentID = nil return .send(.async(.fetchPerspectives(reset: true))) case let .sortTapped(sort): state.selectedSort = sort + state.reportTargetCommentID = nil return .send(.async(.fetchPerspectives(reset: true))) case let .likeTapped(id): - guard let index = state.comments.firstIndex(where: { $0.id == id }) else { return .none } + guard let index = state.comments.firstIndex(where: { $0.id == id }), + let commentId = state.comments[index].perspectiveId + else { return .none } + let wasLiked = state.comments[index].isLiked state.comments[index].isLiked.toggle() state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 - return .none + return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: wasLiked))) case .sendTapped: let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -178,6 +210,7 @@ extension CommentFeature { at: 0 ) state.commentText = "" + state.reportTargetCommentID = nil return .none } } @@ -219,6 +252,20 @@ extension CommentFeature { return await send(.inner(.perspectivesResponse(result, reset: reset))) } .cancellable(id: CancelID.fetchPerspectives, cancelInFlight: true) + + case let .toggleLike(commentId, currentlyLiked): + return .run { [repository = commentRepository] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikeComment(commentId: commentId) + } else { + try await repository.likeComment(commentId: commentId) + } + } + .mapError(CommentError.from) + return await send(.inner(.likeResponse(result))) + } + .cancellable(id: CancelID.toggleLike, cancelInFlight: false) } } @@ -251,6 +298,18 @@ extension CommentFeature { Log.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)") } return .none + + case let .likeResponse(result): + switch result { + case let .success(payload): + if let index = state.comments.firstIndex(where: { $0.perspectiveId == payload.perspectiveId }) { + state.comments[index].likeCount = payload.likeCount + state.comments[index].isLiked = payload.isLiked + } + case let .failure(error): + Log.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)") + } + return .none } } @@ -361,20 +420,9 @@ public struct VoteOptionSummary: Equatable { } } -public enum CommentOption: Equatable { - case a - case b - - public var label: String { - switch self { - case .a: "A" - case .b: "B" - } - } -} - public struct CommentItem: Equatable, Identifiable { public let id: UUID + public var perspectiveId: Int? public var author: String public var timeAgo: String public var option: CommentOption @@ -386,6 +434,7 @@ public struct CommentItem: Equatable, Identifiable { public init( id: UUID = UUID(), + perspectiveId: Int? = nil, author: String, timeAgo: String, option: CommentOption, @@ -396,6 +445,7 @@ public struct CommentItem: Equatable, Identifiable { createdOrder: Int ) { self.id = id + self.perspectiveId = perspectiveId self.author = author self.timeAgo = timeAgo self.option = option @@ -411,6 +461,7 @@ public struct CommentItem: Equatable, Identifiable { let optionFallback: CommentOption = item.option.label == "B" ? .b : .a self.init( id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + perspectiveId: item.perspectiveId, author: item.user.nickname, timeAgo: Self.relativeTimeString(from: item.createdAt), option: optionFallback, diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 95723942..7db111f9 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem +import Entity @ViewAction(for: CommentFeature.self) public struct CommentView: View { @@ -38,6 +39,7 @@ public struct CommentView: View { .contentShape(Rectangle()) .onTapGesture { isCommentFocused = false + send(.reportPopupDismissed) } .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) @@ -233,12 +235,16 @@ private extension CommentView { VStack(alignment: .leading, spacing: 8) { commentHeader(comment) - Text(comment.content) - .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral400) - .lineSpacing(13 * 0.4) - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical, 2) + Button { send(.replyTapped(comment.id)) } label: { + Text(comment.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) commentActions(comment) } @@ -279,16 +285,35 @@ private extension CommentView { Spacer() - Button { send(.moreTapped(comment.id)) } label: { + Button { send(.reportButtonTapped(comment.id)) } label: { Image(systemName: "ellipsis") .font(.system(size: 18, weight: .regular)) .frame(width: 24, height: 24) } .buttonStyle(.plain) .foregroundStyle(.neutral300) + .overlay(alignment: .topTrailing) { + if store.reportTargetCommentID == comment.id { + reportPopup(comment.id) + .offset(x: -2, y: 30) + .zIndex(1) + } + } } } + func reportPopup(_ id: UUID) -> some View { + Button { send(.reportConfirmTapped(id)) } label: { + Image(systemName: "light.beacon.max.fill") + .font(.system(size: 17, weight: .medium)) + .frame(width: 24, height: 24) + .foregroundStyle(.beige50) + .frame(width: 34, height: 34) + .background(.primary500, in: Capsule()) + } + .buttonStyle(.plain) + } + func optionBadge(_ option: CommentOption) -> some View { let summary = option == .a ? store.voteSummary.optionA : store.voteSummary.optionB return Text("\(option.label) \(summary.title)") @@ -301,9 +326,12 @@ private extension CommentView { func commentActions(_ comment: CommentItem) -> some View { HStack(spacing: 12) { - Text("더보기") - .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral300) + Button { send(.moreTapped(comment.id)) } label: { + Text("더보기") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + } + .buttonStyle(.plain) Spacer() diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift new file mode 100644 index 00000000..386b8755 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -0,0 +1,114 @@ +// +// CommentReplyFeature.swift +// Chat +// + +import Foundation + +import ComposableArchitecture +import Entity + +@Reducer +public struct CommentReplyFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var parentComment: CommentItem + public var replies: [CommentReplyItem] + public var replyText: String = "" + + public var isSendEnabled: Bool { + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public init( + parentComment: CommentItem, + replies: [CommentReplyItem]? = nil + ) { + self.parentComment = parentComment + self.replies = replies ?? CommentReplyItem.mocks(for: parentComment.option) + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backButtonTapped + case parentLikeTapped + case replyLikeTapped(UUID) + case sendTapped + } + + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.replyText): + if state.replyText.count > 200 { + state.replyText = String(state.replyText.prefix(200)) + } + return .none + + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .delegate: + return .none + } + } + } +} + +extension CommentReplyFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .parentLikeTapped: + state.parentComment.isLiked.toggle() + state.parentComment.likeCount += state.parentComment.isLiked ? 1 : -1 + return .none + + case let .replyLikeTapped(id): + guard let index = state.replies.firstIndex(where: { $0.id == id }) else { return .none } + state.replies[index].isLiked.toggle() + state.replies[index].likeCount += state.replies[index].isLiked ? 1 : -1 + return .none + + case .sendTapped: + let text = state.replyText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return .none } + state.replies.insert( + CommentReplyItem( + author: "나", + timeAgo: "방금 전", + option: state.parentComment.option, + content: text, + likeCount: 0, + createdOrder: (state.replies.map(\.createdOrder).max() ?? 0) + 1 + ), + at: 0 + ) + state.parentComment.replyCount += 1 + state.replyText = "" + return .none + } + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift new file mode 100644 index 00000000..51c1d781 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -0,0 +1,306 @@ +// +// CommentReplyView.swift +// Chat +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: CommentReplyFeature.self) +public struct CommentReplyView: View { + @Bindable public var store: StoreOf + @FocusState private var isReplyFocused: Bool + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + parentCommentSection + replySection + } + .padding(.bottom, 16) + } + .scrollDismissesKeyboard(.interactively) + inputBar + } + .background(Color.beige200.ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isReplyFocused = false + } + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } +} + +// MARK: - Navigation + +private extension CommentReplyView { + var navigationBar: some View { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + Text("댓글") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + + Spacer() + + Color.clear.frame(width: 24, height: 24) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .foregroundStyle(.neutral500) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Content + +private extension CommentReplyView { + var parentCommentSection: some View { + VStack(spacing: 0) { + commentCard( + author: store.parentComment.author, + timeAgo: store.parentComment.timeAgo, + option: store.parentComment.option, + content: store.parentComment.content, + replyCount: store.parentComment.replyCount, + likeCount: store.parentComment.likeCount, + isLiked: store.parentComment.isLiked, + background: .beige50, + showsReplyCount: true, + likeAction: { send(.parentLikeTapped) } + ) + } + .padding(12) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + var replySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("답글 \(store.replies.count)개") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 12) + + ForEach(store.replies) { reply in + commentCard( + author: reply.author, + timeAgo: reply.timeAgo, + option: reply.option, + content: reply.content, + replyCount: nil, + likeCount: reply.likeCount, + isLiked: reply.isLiked, + background: .beige50, + showsReplyCount: false, + likeAction: { send(.replyLikeTapped(reply.id)) } + ) + } + } + .padding(.bottom, 24) + .background(.beige50) + } + + func commentCard( + author: String, + timeAgo: String, + option: CommentOption, + content: String, + replyCount: Int?, + likeCount: Int, + isLiked: Bool, + background: Color, + showsReplyCount: Bool, + likeAction: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + commentHeader(author: author, timeAgo: timeAgo, option: option) + + Text(content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + + HStack(spacing: 12) { + Spacer() + + if showsReplyCount, let replyCount { + actionLabel(systemName: "message", text: "\(replyCount)") + } + + Button(action: likeAction) { + actionLabel( + systemName: isLiked ? "heart.fill" : "heart", + text: formattedCount(likeCount) + ) + } + .buttonStyle(.plain) + } + .foregroundStyle(.neutral300) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + .padding(.horizontal, 12) + } + + func commentHeader( + author: String, + timeAgo: String, + option: CommentOption + ) -> some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(.beige600) + .frame(width: 36, height: 36) + .overlay { + Text(String(author.prefix(1))) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(author) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .lineLimit(1) + + Text(timeAgo) + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral300) + } + + optionBadge(option) + } + + Spacer() + + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + .foregroundStyle(.neutral300) + } + } + + func optionBadge(_ option: CommentOption) -> some View { + Text(option.label == "A" ? "변기는 변기다" : "예술이다") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(option == .a ? .primary500 : .beige50) + .padding(.horizontal, option == .a ? 4 : 6) + .padding(.vertical, 2) + .background(option == .a ? Color.beige600 : Color.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + + func actionLabel(systemName: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .frame(width: 16, height: 16) + + Text(text) + .pretendardFont(family: .Medium, size: 12) + } + } +} + +// MARK: - Input + +private extension CommentReplyView { + var inputBar: some View { + HStack(alignment: .bottom, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + TextField("내 의견은 어쩌구 저쩌구", text: $store.replyText, axis: .vertical) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineLimit(1 ... 3) + .focused($isReplyFocused) + + Text("\(store.replyText.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral400) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.beige50) + + Button { send(.sendTapped) } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + .frame(width: 36, height: 36) + .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!store.isSendEnabled) + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 24) + .frame(height: 128) + .background(.beige400) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige800) + .frame(height: 1) + } + } +} + +// MARK: - Format + +private extension CommentReplyView { + func formattedCount(_ count: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: count)) ?? "\(count)" + } +} + +#Preview { + CommentReplyView( + store: Store( + initialState: CommentReplyFeature.State(parentComment: CommentItem.mocks[0]) + ) { + CommentReplyFeature() + } + ) +} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index fa5066cc..ebedaf42 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -90,6 +90,13 @@ extension ChatCoordinator { case .routeAction(_, action: .comment(.delegate(.dismiss))): return .send(.view(.backAction)) + case let .routeAction(_, action: .comment(.delegate(.openReply(comment)))): + state.routes.push(.commentReply(.init(parentComment: comment))) + return .none + + case .routeAction(_, action: .commentReply(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -127,6 +134,7 @@ extension ChatCoordinator { case preVote(PreVoteFeature) case chatRoom(ChatRoomFeature) case comment(CommentFeature) + case commentReply(CommentReplyFeature) } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift index 6e97167d..67bf110d 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -29,6 +29,9 @@ public struct ChatCoordinatorView: View { case let .comment(commentStore): CommentView(store: commentStore) .toolbar(.hidden, for: .tabBar) + case let .commentReply(commentReplyStore): + CommentReplyView(store: commentReplyStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index a71aa913..2fe21eff 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -45,7 +45,7 @@ struct CustomConfirmationPopup: View { .onTapGesture(perform: onCancel) popupContent - .padding(.horizontal, 20) + .padding(.horizontal, popupHorizontalPadding) .offset(y: isContentVisible ? 0 : 120) .opacity(isContentVisible ? 1 : 0) } @@ -56,6 +56,15 @@ struct CustomConfirmationPopup: View { } } + private var popupHorizontalPadding: CGFloat { + switch style { + case .confirmation: + 20 + case .finalVote: + 0 + } + } + @ViewBuilder private var popupContent: some View { switch style { @@ -162,6 +171,7 @@ struct CustomConfirmationPopup: View { RoundedRectangle(cornerRadius: 2) .stroke(.primary500, lineWidth: 1.5) ) + .opacity(0.9) .onTapGesture {} } } From ab6c7e191e61f20f1037ce1039742c70f2485420 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 01:35:14 +0900 Subject: [PATCH 10/42] =?UTF-8?q?feat:=20=EB=8C=80=EB=8C=93=EA=B8=80(persp?= =?UTF-8?q?ective=20comments)=20API=205=EC=A2=85=20=EC=97=B0=EB=8F=99=20+?= =?UTF-8?q?=20CommentReplyFeature=20=EC=8B=A4=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PieckeDomain.perspective 추가 (api/v1/perspectives/) - Entity - PerspectiveComment / PerspectiveCommentPage / PerspectiveCommentUser - PerspectiveCommentMutationResult (create/update 응답) - CommentReplyItem 에 commentId 필드 + PerspectiveComment 매핑 init 추가 - Data Model: Perspective DTO/매퍼 (cursor 페이지 + create/update 응답 + 부모 perspective detail 재사용) - PerspectiveAPI / PerspectiveService - detail (GET) / listLabeledComments (GET + cursor·size) / createComment (POST) / updateComment (PUT) / deleteComment (DELETE) - PerspectiveCommentBody { content } - DomainInterface: PerspectiveInterface + Default + DependencyKey - Repository: PerspectiveRepositoryImpl (MoyaProvider.authorized) + EmptyDTO - App DI: PerspectiveRepositoryImpl as PerspectiveInterface 등록 - CommentReplyFeature - State: perspectiveId / parentComment / replies / nextCursor / hasNext / editingCommentId / 로딩 플래그 - AsyncAction: fetchParent / fetchReplies(reset:) / createReply / updateReply / deleteReply / toggleParentLike / toggleReplyLike - InnerAction 으로 응답 단일화, 좋아요는 /comments/{id}/likes 재사용 - onAppear 시 부모 + 답글 페이지 병렬 페치, sendTapped 시 createReply/updateReply 분기 --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Data/API/Sources/Base/PieckeDomain.swift | 3 + .../Sources/Perspective/PerspectiveAPI.swift | 29 ++ .../DTO/PerspectiveCommentDataDTO.swift | 41 +++ .../Mapper/PerspectiveCommentDataDTO+.swift | 69 ++++ .../PerspectiveRepositoryImpl.swift | 110 +++++++ .../Perspective/PerspectiveService.swift | 84 +++++ .../Perspective/PerspectiveInterface.swift | 90 ++++++ .../Domain/Entity/Sources/Home/Comment.swift | 33 ++ .../Sources/Home/PerspectiveComment.swift | 85 +++++ .../Comment/Reducer/CommentFeature.swift | 49 ++- .../Sources/Comment/View/CommentView.swift | 36 +-- .../Reducer/CommentReplyFeature.swift | 294 +++++++++++++++++- .../CommentReply/View/CommentReplyView.swift | 5 +- .../Coordinator/Reducer/ChatCoordinator.swift | 9 +- .../Alert/CustomPopup/CustomAlertState.swift | 11 + .../CustomConfirmationPopupView.swift | 22 +- 17 files changed, 925 insertions(+), 46 deletions(-) create mode 100644 Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift create mode 100644 Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Perspective/PerspectiveService.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index ae5db93d..6678ba1f 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -38,6 +38,7 @@ public final class AppDIManager: Sendable { .register { HomeRepositoryImpl() as HomeInterface } .register { BattleRepositoryImpl() as BattleInterface } .register { CommentRepositoryImpl() as CommentInterface } + .register { PerspectiveRepositoryImpl() as PerspectiveInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 49d46e4b..31261ddf 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -16,6 +16,7 @@ public enum PieckeDomain { case poll case battle case comment + case perspective } extension PieckeDomain: DomainType { @@ -37,6 +38,8 @@ extension PieckeDomain: DomainType { "api/v1/battles/" case .comment: "api/v1/comments/" + case .perspective: + "api/v1/perspectives/" } } } diff --git a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift new file mode 100644 index 00000000..554aea04 --- /dev/null +++ b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift @@ -0,0 +1,29 @@ +// +// PerspectiveAPI.swift +// API +// + +import Foundation + +public enum PerspectiveAPI { + case detail(perspectiveId: Int) + case listLabeledComments(perspectiveId: Int) + case createComment(perspectiveId: Int) + case updateComment(perspectiveId: Int, commentId: Int) + case deleteComment(perspectiveId: Int, commentId: Int) + + public var description: String { + switch self { + case let .detail(perspectiveId): + "\(perspectiveId)" + case let .listLabeledComments(perspectiveId): + "\(perspectiveId)/comments/labeled" + case let .createComment(perspectiveId): + "\(perspectiveId)/comments" + case let .updateComment(perspectiveId, commentId): + "\(perspectiveId)/comments/\(commentId)" + case let .deleteComment(perspectiveId, commentId): + "\(perspectiveId)/comments/\(commentId)" + } + } +} diff --git a/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift b/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift new file mode 100644 index 00000000..2c1c589d --- /dev/null +++ b/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift @@ -0,0 +1,41 @@ +// +// PerspectiveCommentDataDTO.swift +// Model +// + +import Foundation + +public struct PerspectiveCommentPageDataDTO: Decodable { + public let items: [PerspectiveCommentDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct PerspectiveCommentDTO: Decodable { + public let commentId: Int + public let user: PerspectiveCommentUserDTO + public let stance: String? + public let content: String + public let likeCount: Int + public let isLiked: Bool? + public let isMine: Bool? + public let createdAt: String? +} + +public struct PerspectiveCommentUserDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +/// POST/PUT /api/v1/perspectives/{pid}/comments[/{cid}] 응답. +public struct PerspectiveCommentMutationDataDTO: Decodable { + public let commentId: Int + public let content: String + public let updatedAt: String? +} + +public typealias PerspectiveCommentPageResponseDTO = BaseResponseDTO +public typealias PerspectiveCommentMutationResponseDTO = BaseResponseDTO +public typealias PerspectiveDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift b/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift new file mode 100644 index 00000000..ca8246dc --- /dev/null +++ b/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift @@ -0,0 +1,69 @@ +// +// PerspectiveCommentDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension PerspectiveCommentPageDataDTO { + func toDomain() -> PerspectiveCommentPage { + PerspectiveCommentPage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension PerspectiveCommentDTO { + func toDomain() -> PerspectiveComment { + PerspectiveComment( + commentId: commentId, + user: user.toDomain(), + stance: stance ?? "", + content: content, + likeCount: likeCount, + isLiked: isLiked ?? false, + isMine: isMine ?? false, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension PerspectiveCommentUserDTO { + func toDomain() -> PerspectiveCommentUser { + PerspectiveCommentUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension PerspectiveCommentMutationDataDTO { + func toDomain() -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult( + commentId: commentId, + content: content, + updatedAt: updatedAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift new file mode 100644 index 00000000..e52195b9 --- /dev/null +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -0,0 +1,110 @@ +// +// PerspectiveRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + let dto: PerspectiveDetailResponseDTO = try await provider.request( + .detail(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 상세 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty detail payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + let dto: PerspectiveCommentPageResponseDTO = try await provider.request( + .listLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 목록 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty list payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( + .createComment(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 작성 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty create payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( + .updateComment( + perspectiveId: perspectiveId, + commentId: commentId, + body: PerspectiveCommentBody(content: content) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 수정 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty update payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func deleteComment(perspectiveId: Int, commentId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .deleteComment(perspectiveId: perspectiveId, commentId: commentId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "대댓글 삭제 실패" + throw CommentError.backendError(message) + } + } +} + +public struct EmptyDTO: Decodable {} diff --git a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift new file mode 100644 index 00000000..2582228d --- /dev/null +++ b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift @@ -0,0 +1,84 @@ +// +// PerspectiveService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public struct PerspectiveCommentBody: Encodable { + public let content: String + public init(content: String) { self.content = content } +} + +public enum PerspectiveService { + case detail(perspectiveId: Int) + case listLabeledComments(perspectiveId: Int, cursor: String?, size: Int?) + case createComment(perspectiveId: Int, body: PerspectiveCommentBody) + case updateComment(perspectiveId: Int, commentId: Int, body: PerspectiveCommentBody) + case deleteComment(perspectiveId: Int, commentId: Int) +} + +extension PerspectiveService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .perspective } + + public var urlPath: String { + switch self { + case let .detail(perspectiveId): + PerspectiveAPI.detail(perspectiveId: perspectiveId).description + case let .listLabeledComments(perspectiveId, _, _): + PerspectiveAPI.listLabeledComments(perspectiveId: perspectiveId).description + case let .createComment(perspectiveId, _): + PerspectiveAPI.createComment(perspectiveId: perspectiveId).description + case let .updateComment(perspectiveId, commentId, _): + PerspectiveAPI.updateComment(perspectiveId: perspectiveId, commentId: commentId).description + case let .deleteComment(perspectiveId, commentId): + PerspectiveAPI.deleteComment(perspectiveId: perspectiveId, commentId: commentId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .detail: + .get + case .listLabeledComments: + .get + case .createComment: + .post + case .updateComment: + .put + case .deleteComment: + .delete + } + } + + public var parameters: [String: Any]? { + switch self { + case .detail: + return nil + case let .listLabeledComments(_, cursor, size): + var query: [String: Any] = [:] + if let cursor { query["cursor"] = cursor } + if let size { query["size"] = size } + return query.isEmpty ? nil : query + case let .createComment(_, body): + return body.toDictionary + case let .updateComment(_, _, body): + return body.toDictionary + case .deleteComment: + return nil + } + } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift new file mode 100644 index 00000000..90fb1e54 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -0,0 +1,90 @@ +// +// PerspectiveInterface.swift +// DomainInterface +// + +import Dependencies +import Entity +import Foundation +import WeaveDI + +public protocol PerspectiveInterface: Sendable { + func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective + func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage + func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult + func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult + func deleteComment(perspectiveId: Int, commentId: Int) async throws +} + +public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: BattlePerspectiveUser(userTag: "", nickname: "", characterType: "", characterImageUrl: nil), + option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), + content: "", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: false, + createdAt: nil + ) + } + + public func fetchLabeledComments( + perspectiveId _: Int, + cursor _: String?, + size _: Int? + ) async throws -> PerspectiveCommentPage { + PerspectiveCommentPage(items: [], nextCursor: nil, hasNext: false) + } + + public func createComment( + perspectiveId _: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: 0, content: content, updatedAt: nil) + } + + public func updateComment( + perspectiveId _: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: commentId, content: content, updatedAt: nil) + } + + public func deleteComment(perspectiveId _: Int, commentId _: Int) async throws {} +} + +public struct PerspectiveRepositoryDependency: DependencyKey { + public static var liveValue: PerspectiveInterface { + UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() + } + + public static var testValue: PerspectiveInterface { + UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() + } + + public static var previewValue: PerspectiveInterface = liveValue +} + +public extension DependencyValues { + var perspectiveRepository: PerspectiveInterface { + get { self[PerspectiveRepositoryDependency.self] } + set { self[PerspectiveRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment.swift index ac0b4406..2a618194 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment.swift @@ -61,6 +61,7 @@ public struct Comment: Equatable, Identifiable, Hashable { public struct CommentReplyItem: Equatable, Identifiable { public let id: UUID + public var commentId: Int? public var author: String public var timeAgo: String public var option: CommentOption @@ -71,6 +72,7 @@ public struct CommentReplyItem: Equatable, Identifiable { public init( id: UUID = UUID(), + commentId: Int? = nil, author: String, timeAgo: String, option: CommentOption, @@ -80,6 +82,7 @@ public struct CommentReplyItem: Equatable, Identifiable { createdOrder: Int ) { self.id = id + self.commentId = commentId self.author = author self.timeAgo = timeAgo self.option = option @@ -89,6 +92,36 @@ public struct CommentReplyItem: Equatable, Identifiable { self.createdOrder = createdOrder } + /// 서버 PerspectiveComment 응답을 화면 모델로 변환. + public init(item: PerspectiveComment, parentOption: CommentOption, order: Int) { + let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() + self.init( + id: id, + commentId: item.commentId, + author: item.user.nickname, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: parentOption, + content: item.content, + likeCount: item.likeCount, + isLiked: item.isLiked, + createdOrder: order + ) + } + + private static func deterministicUUID(commentId: Int) -> String { + let hex = String(format: "%012X", commentId) + return "00000000-0000-0000-0002-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } + public static func mocks(for option: CommentOption) -> [CommentReplyItem] { [ .init( diff --git a/Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift new file mode 100644 index 00000000..a7e492be --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift @@ -0,0 +1,85 @@ +// +// PerspectiveComment.swift +// Entity +// +// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. +// + +import Foundation + +public struct PerspectiveCommentPage: Equatable { + public let items: [PerspectiveComment] + public let nextCursor: String? + public let hasNext: Bool + + public init(items: [PerspectiveComment], nextCursor: String?, hasNext: Bool) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct PerspectiveComment: Equatable, Identifiable, Hashable { + public let commentId: Int + public let user: PerspectiveCommentUser + public let stance: String + public let content: String + public let likeCount: Int + public let isLiked: Bool + public let isMine: Bool + public let createdAt: Date? + + public var id: Int { commentId } + + public init( + commentId: Int, + user: PerspectiveCommentUser, + stance: String, + content: String, + likeCount: Int, + isLiked: Bool, + isMine: Bool, + createdAt: Date? + ) { + self.commentId = commentId + self.user = user + self.stance = stance + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdAt = createdAt + } +} + +public struct PerspectiveCommentUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} + +/// 대댓글 작성/수정 응답. +public struct PerspectiveCommentMutationResult: Equatable, Hashable { + public let commentId: Int + public let content: String + public let updatedAt: Date? + + public init(commentId: Int, content: String, updatedAt: Date?) { + self.commentId = commentId + self.content = content + self.updatedAt = updatedAt + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 61c77848..63a30d08 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture +import DesignSystem import DomainInterface import Entity import LogMacro @@ -29,6 +30,7 @@ public struct CommentFeature { public var selectedFilter: CommentFilter = .all public var selectedSort: CommentSort = .popular public var reportTargetCommentID: UUID? + @Presents public var customAlert: CustomAlertState? public var comments: [CommentItem] public var commentText: String = "" @@ -65,6 +67,7 @@ public struct CommentFeature { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } @@ -96,6 +99,11 @@ public struct CommentFeature { case likeResponse(Result) } + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + public enum DelegateAction: Equatable { case dismiss case openReply(CommentItem) @@ -132,10 +140,16 @@ public struct CommentFeature { case let .inner(innerAction): return handleInnerAction(state: &state, action: innerAction) + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + case .delegate: return .none } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -163,11 +177,13 @@ extension CommentFeature { return .send(.delegate(.openReply(comment))) case let .reportButtonTapped(id): - state.reportTargetCommentID = state.reportTargetCommentID == id ? nil : id + state.reportTargetCommentID = id + state.customAlert = .report() return .none case .reportPopupDismissed: state.reportTargetCommentID = nil + state.customAlert = nil return .none case let .reportConfirmTapped(id): @@ -215,6 +231,37 @@ extension CommentFeature { } } + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + guard let id = state.reportTargetCommentID else { + state.customAlert = nil + return .none + } + state.customAlert = nil + return .send(.view(.reportConfirmTapped(id))) + + case .cancelTapped: + state.reportTargetCommentID = nil + state.customAlert = nil + return .none + } + + case .dismiss: + state.reportTargetCommentID = nil + state.customAlert = nil + return .none + } + } + } + private func handleAsyncAction( state: inout State, action: AsyncAction diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 7db111f9..40dbac43 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -39,7 +39,6 @@ public struct CommentView: View { .contentShape(Rectangle()) .onTapGesture { isCommentFocused = false - send(.reportPopupDismissed) } .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) @@ -50,6 +49,7 @@ public struct CommentView: View { hasAnimatedVoteProgress = true } } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } } @@ -165,20 +165,20 @@ private extension CommentView { private extension CommentView { var filterSection: some View { VStack(spacing: 12) { - HStack(spacing: 8) { + HStack(spacing: 0) { ForEach(CommentFilter.allCases, id: \.self) { filter in filterButton(filter) } - Spacer() } + .frame(maxWidth: .infinity) HStack(spacing: 0) { sortButton(.popular) sortButton(.latest) Spacer() } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 12) } @@ -191,9 +191,10 @@ private extension CommentView { Text(filter.title) .pretendardFont(family: .Medium, size: 14) .foregroundStyle(isSelected ? .primary500 : .neutral300) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(isSelected ? Color.primary50 : Color.clear, in: RoundedRectangle(cornerRadius: 2)) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) } .buttonStyle(.plain) } @@ -258,7 +259,7 @@ private extension CommentView { } func commentHeader(_ comment: CommentItem) -> some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 6) { Circle() .fill(.beige600) .frame(width: 36, height: 36) @@ -292,26 +293,7 @@ private extension CommentView { } .buttonStyle(.plain) .foregroundStyle(.neutral300) - .overlay(alignment: .topTrailing) { - if store.reportTargetCommentID == comment.id { - reportPopup(comment.id) - .offset(x: -2, y: 30) - .zIndex(1) - } - } - } - } - - func reportPopup(_ id: UUID) -> some View { - Button { send(.reportConfirmTapped(id)) } label: { - Image(systemName: "light.beacon.max.fill") - .font(.system(size: 17, weight: .medium)) - .frame(width: 24, height: 24) - .foregroundStyle(.beige50) - .frame(width: 34, height: 34) - .background(.primary500, in: Capsule()) } - .buttonStyle(.plain) } func optionBadge(_ option: CommentOption) -> some View { diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index 386b8755..c4297ca1 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -2,11 +2,21 @@ // CommentReplyFeature.swift // Chat // +// 대댓글 화면. +// - GET /api/v1/perspectives/{pid} (부모 댓글 상세) +// - GET /api/v1/perspectives/{pid}/comments/labeled (답글 페이지) +// - POST /api/v1/perspectives/{pid}/comments (답글 작성) +// - PUT /api/v1/perspectives/{pid}/comments/{cid} (답글 수정) +// - DELETE /api/v1/perspectives/{pid}/comments/{cid} (답글 삭제) +// - POST/DELETE /api/v1/comments/{cid}/likes (좋아요 토글) +// import Foundation import ComposableArchitecture +import DomainInterface import Entity +import LogMacro @Reducer public struct CommentReplyFeature { @@ -14,18 +24,26 @@ public struct CommentReplyFeature { @ObservableState public struct State: Equatable { + public var perspectiveId: Int public var parentComment: CommentItem public var replies: [CommentReplyItem] public var replyText: String = "" + public var isLoadingDetail: Bool = false + public var isLoadingReplies: Bool = false + public var nextCursor: String? + public var hasNext: Bool = false + public var editingCommentId: Int? public var isSendEnabled: Bool { !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } public init( + perspectiveId: Int, parentComment: CommentItem, replies: [CommentReplyItem]? = nil ) { + self.perspectiveId = perspectiveId self.parentComment = parentComment self.replies = replies ?? CommentReplyItem.mocks(for: parentComment.option) } @@ -34,21 +52,57 @@ public struct CommentReplyFeature { public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) + case async(AsyncAction) + case inner(InnerAction) case delegate(DelegateAction) } @CasePathable public enum View { + case onAppear case backButtonTapped case parentLikeTapped case replyLikeTapped(UUID) case sendTapped + case beginEditing(commentId: Int, content: String) + case cancelEditing + case deleteTapped(commentId: Int) + } + + public enum AsyncAction: Equatable { + case fetchParent + case fetchReplies(reset: Bool) + case createReply(content: String) + case updateReply(commentId: Int, content: String) + case deleteReply(commentId: Int) + case toggleParentLike(currentlyLiked: Bool) + case toggleReplyLike(commentId: Int, currentlyLiked: Bool) + } + + public enum InnerAction: Equatable { + case parentResponse(Result) + case repliesResponse(Result, reset: Bool) + case createResponse(Result) + case updateResponse(Result, commentId: Int) + case deleteResponse(Result) + case parentLikeResponse(Result) + case replyLikeResponse(Result) } public enum DelegateAction: Equatable { case dismiss } + nonisolated enum CancelID: Hashable { + case fetchParent + case fetchReplies + case mutate + case like + } + + @Dependency(\.perspectiveRepository) private var perspectiveRepository + @Dependency(\.commentRepository) private var commentRepository + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -65,6 +119,12 @@ public struct CommentReplyFeature { case let .view(viewAction): return handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: return .none } @@ -72,42 +132,246 @@ public struct CommentReplyFeature { } } +// MARK: - View handler + extension CommentReplyFeature { private func handleViewAction( state: inout State, action: View ) -> Effect { switch action { + case .onAppear: + return .merge( + .send(.async(.fetchParent)), + .send(.async(.fetchReplies(reset: true))) + ) + case .backButtonTapped: return .send(.delegate(.dismiss)) case .parentLikeTapped: + let wasLiked = state.parentComment.isLiked state.parentComment.isLiked.toggle() state.parentComment.likeCount += state.parentComment.isLiked ? 1 : -1 - return .none + return .send(.async(.toggleParentLike(currentlyLiked: wasLiked))) case let .replyLikeTapped(id): - guard let index = state.replies.firstIndex(where: { $0.id == id }) else { return .none } + guard let index = state.replies.firstIndex(where: { $0.id == id }), + let commentId = state.replies[index].commentId + else { return .none } + let wasLiked = state.replies[index].isLiked state.replies[index].isLiked.toggle() state.replies[index].likeCount += state.replies[index].isLiked ? 1 : -1 - return .none + return .send(.async(.toggleReplyLike(commentId: commentId, currentlyLiked: wasLiked))) case .sendTapped: let text = state.replyText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return .none } - state.replies.insert( - CommentReplyItem( - author: "나", - timeAgo: "방금 전", - option: state.parentComment.option, - content: text, - likeCount: 0, - createdOrder: (state.replies.map(\.createdOrder).max() ?? 0) + 1 - ), - at: 0 - ) - state.parentComment.replyCount += 1 state.replyText = "" + if let editingId = state.editingCommentId { + state.editingCommentId = nil + return .send(.async(.updateReply(commentId: editingId, content: text))) + } + return .send(.async(.createReply(content: text))) + + case let .beginEditing(commentId, content): + state.editingCommentId = commentId + state.replyText = content + return .none + + case .cancelEditing: + state.editingCommentId = nil + state.replyText = "" + return .none + + case let .deleteTapped(commentId): + return .send(.async(.deleteReply(commentId: commentId))) + } + } +} + +// MARK: - Async handler + +extension CommentReplyFeature { + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchParent: + state.isLoadingDetail = true + let pid = state.perspectiveId + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.fetchPerspective(perspectiveId: pid) + } + .mapError(CommentError.from) + return await send(.inner(.parentResponse(result))) + } + .cancellable(id: CancelID.fetchParent, cancelInFlight: true) + + case let .fetchReplies(reset): + state.isLoadingReplies = true + let pid = state.perspectiveId + let cursor = reset ? nil : state.nextCursor + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 20) + } + .mapError(CommentError.from) + return await send(.inner(.repliesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetchReplies, cancelInFlight: true) + + case let .createReply(content): + let pid = state.perspectiveId + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.createComment(perspectiveId: pid, content: content) + } + .mapError(CommentError.from) + return await send(.inner(.createResponse(result))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .updateReply(commentId, content): + let pid = state.perspectiveId + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.updateComment(perspectiveId: pid, commentId: commentId, content: content) + } + .mapError(CommentError.from) + return await send(.inner(.updateResponse(result, commentId: commentId))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .deleteReply(commentId): + let pid = state.perspectiveId + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.deleteComment(perspectiveId: pid, commentId: commentId) + return commentId + } + .mapError(CommentError.from) + return await send(.inner(.deleteResponse(result))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .toggleParentLike(currentlyLiked): + let commentId = state.perspectiveId + return .run { [repository = commentRepository] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikeComment(commentId: commentId) + } else { + try await repository.likeComment(commentId: commentId) + } + } + .mapError(CommentError.from) + return await send(.inner(.parentLikeResponse(result))) + } + .cancellable(id: CancelID.like, cancelInFlight: false) + + case let .toggleReplyLike(commentId, currentlyLiked): + return .run { [repository = commentRepository] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikeComment(commentId: commentId) + } else { + try await repository.likeComment(commentId: commentId) + } + } + .mapError(CommentError.from) + return await send(.inner(.replyLikeResponse(result))) + } + .cancellable(id: CancelID.like, cancelInFlight: false) + } + } +} + +// MARK: - Inner handler + +extension CommentReplyFeature { + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .parentResponse(result): + state.isLoadingDetail = false + switch result { + case let .success(perspective): + state.parentComment = CommentItem(item: perspective, order: 0) + case let .failure(error): + Log.error("[CommentReplyFeature] fetchParent failed: \(error.localizedDescription)") + } + return .none + + case let .repliesResponse(result, reset): + state.isLoadingReplies = false + switch result { + case let .success(page): + let mapped = page.items.enumerated().map { idx, item in + CommentReplyItem(item: item, parentOption: state.parentComment.option, order: idx) + } + state.replies = reset ? mapped : state.replies + mapped + state.nextCursor = page.nextCursor + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[CommentReplyFeature] fetchReplies failed: \(error.localizedDescription)") + } + return .none + + case let .createResponse(result): + switch result { + case .success: + return .send(.async(.fetchReplies(reset: true))) + case let .failure(error): + Log.error("[CommentReplyFeature] createReply failed: \(error.localizedDescription)") + return .none + } + + case let .updateResponse(result, _): + switch result { + case let .success(payload): + if let index = state.replies.firstIndex(where: { $0.commentId == payload.commentId }) { + state.replies[index].content = payload.content + } + case let .failure(error): + Log.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)") + } + return .none + + case let .deleteResponse(result): + switch result { + case let .success(commentId): + state.replies.removeAll { $0.commentId == commentId } + if state.parentComment.replyCount > 0 { state.parentComment.replyCount -= 1 } + case let .failure(error): + Log.error("[CommentReplyFeature] deleteReply failed: \(error.localizedDescription)") + } + return .none + + case let .parentLikeResponse(result): + switch result { + case let .success(payload): + state.parentComment.likeCount = payload.likeCount + state.parentComment.isLiked = payload.isLiked + case let .failure(error): + Log.error("[CommentReplyFeature] toggleParentLike failed: \(error.localizedDescription)") + } + return .none + + case let .replyLikeResponse(result): + switch result { + case let .success(payload): + if let index = state.replies.firstIndex(where: { $0.commentId == payload.perspectiveId }) { + state.replies[index].likeCount = payload.likeCount + state.replies[index].isLiked = payload.isLiked + } + case let .failure(error): + Log.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)") + } return .none } } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 51c1d781..9335ecf0 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -298,7 +298,10 @@ private extension CommentReplyView { #Preview { CommentReplyView( store: Store( - initialState: CommentReplyFeature.State(parentComment: CommentItem.mocks[0]) + initialState: CommentReplyFeature.State( + perspectiveId: CommentItem.mocks[0].perspectiveId ?? 0, + parentComment: CommentItem.mocks[0] + ) ) { CommentReplyFeature() } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index ebedaf42..2910b8c2 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -91,7 +91,14 @@ extension ChatCoordinator { return .send(.view(.backAction)) case let .routeAction(_, action: .comment(.delegate(.openReply(comment)))): - state.routes.push(.commentReply(.init(parentComment: comment))) + state.routes.push( + .commentReply( + .init( + perspectiveId: comment.perspectiveId ?? 0, + parentComment: comment + ) + ) + ) return .none case .routeAction(_, action: .commentReply(.delegate(.dismiss))): diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 11169e2a..4476b900 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -35,6 +35,7 @@ public struct CustomAlertState: Equatable { public enum CustomAlertStyle: Equatable { case confirmation case finalVote + case report } @CasePathable @@ -78,4 +79,14 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .finalVote ) } + + static func report() -> CustomAlertState { + CustomAlertState( + title: "", + confirmTitle: "신고", + cancelTitle: "", + isDestructive: true, + style: .report + ) + } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 2fe21eff..37899b0f 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -60,7 +60,7 @@ struct CustomConfirmationPopup: View { switch style { case .confirmation: 20 - case .finalVote: + case .finalVote, .report: 0 } } @@ -72,6 +72,8 @@ struct CustomConfirmationPopup: View { confirmationContent case .finalVote: finalVoteContent + case .report: + reportContent } } @@ -174,4 +176,22 @@ struct CustomConfirmationPopup: View { .opacity(0.9) .onTapGesture {} } + + private var reportContent: some View { + Button(action: onConfirm) { + HStack(spacing: 4) { + Image(systemName: "light.beacon.max") + .font(.system(size: 15, weight: .regular)) + .frame(width: 24, height: 24) + + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(.beige50) + .frame(width: 70, height: 34) + .background(.primary500, in: Capsule()) + } + .buttonStyle(.plain) + .onTapGesture {} + } } From 78a64ba10eb4c1debe8c936e487bdc01f65ed74c Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 02:03:43 +0900 Subject: [PATCH 11/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API(POST=20/perspectives/{pid}/comments)=20CommentV?= =?UTF-8?q?iew=20=EC=97=B0=EB=8F=99=20+=20=EC=A0=95=EB=A0=AC=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=95=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentFeature.State.perspectiveId(Int?) / isSubmitting 추가 - isSendEnabled 가 perspectiveId 존재 + 비제출 중일 때만 활성 - AsyncAction.createComment(perspectiveId:, content:) + InnerAction.createCommentResponse 추가 - sendTapped 시 perspectiveRepository.createComment(POST /perspectives/{pid}/comments) 호출 - 성공 시 fetchPerspectives(reset:true) 로 리스트 재로딩 - @Dependency(\.perspectiveRepository) 주입 - BattleService.createPerspective + CreatePerspectiveRequest 신설 (battle 단위 댓글 작성용도 함께 준비 — 백엔드 endpoint 확정 시 활용) - BattleInterface.createPerspective + Default/Repository 구현 (BattlePerspective 반환) - CommentView 정렬 버튼 색상을 디자인 토큰(.primary500 / .primary50) 으로 정리, hardcoded hex 제거 - CommentView 필터 탭 라벨을 voteSummary.optionA/B.title 로 동적 매핑 (.pen `전체/<옵션 title>/<옵션 title>` 매칭) - 필터 탭 하단 underline 추가 (선택 primary500 2.5pt, 비선택 neutral200 1pt) --- .../Sources/Battle/BattleRepositoryImpl.swift | 21 ++++++++ .../Sources/Battle/BattleService.swift | 16 ++++++ .../Sources/Battle/BattleInterface.swift | 5 ++ .../Battle/DefaultBattleRepositoryImpl.swift | 18 +++++++ .../Comment/Reducer/CommentFeature.swift | 51 +++++++++++++------ .../Sources/Comment/View/CommentView.swift | 25 +++++++-- 6 files changed, 116 insertions(+), 20 deletions(-) diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index ff4e5ac9..ebab8c88 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -106,6 +106,27 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + public func createPerspective( + battleId: Int, + content: String, + optionId: Int + ) async throws -> BattlePerspective { + let dto: BaseResponseDTO = try await provider.request( + .createPerspective( + battleId: battleId, + body: CreatePerspectiveRequest(content: content, optionId: optionId) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 작성 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty createPerspective payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + public func fetchScenario(battleId: Int) async throws -> BattleScenario { let dto: BattleScenarioResponseDTO = try await provider.request( .scenario(battleId: battleId) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 478b6f7e..26acb45e 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -10,6 +10,15 @@ import Foundations import AsyncMoya +public struct CreatePerspectiveRequest: Encodable { + public let content: String + public let optionId: Int + public init(content: String, optionId: Int) { + self.content = content + self.optionId = optionId + } +} + public enum BattleService { case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) @@ -17,6 +26,7 @@ public enum BattleService { case scenario(battleId: Int) case voteStats(battleId: Int) case perspectives(battleId: Int, cursor: String?, size: Int?, optionLabel: String?, sort: String?) + case createPerspective(battleId: Int, body: CreatePerspectiveRequest) } extension BattleService: BaseTargetType { @@ -38,6 +48,8 @@ extension BattleService: BaseTargetType { BattleAPI.voteStats(battleId: battleId).description case let .perspectives(battleId, _, _, _, _): BattleAPI.perspectives(battleId: battleId).description + case let .createPerspective(battleId, _): + BattleAPI.perspectives(battleId: battleId).description } } @@ -57,6 +69,8 @@ extension BattleService: BaseTargetType { .get case .perspectives: .get + case .createPerspective: + .post } } @@ -79,6 +93,8 @@ extension BattleService: BaseTargetType { if let optionLabel { query["optionLabel"] = optionLabel } if let sort { query["sort"] = sort } return query.isEmpty ? nil : query + case let .createPerspective(_, body): + return body.toDictionary } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index d3644224..9d2eaa63 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -20,6 +20,11 @@ public protocol BattleInterface: Sendable { optionLabel: String?, sort: BattlePerspectiveSort? ) async throws -> BattlePerspectivePage + func createPerspective( + battleId: Int, + content: String, + optionId: Int + ) async throws -> BattlePerspective } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 3ad26368..a7f1445a 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -66,4 +66,22 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { nodes: [] ) } + + public func createPerspective( + battleId _: Int, + content: String, + optionId: Int + ) async throws -> BattlePerspective { + BattlePerspective( + perspectiveId: 0, + user: BattlePerspectiveUser(userTag: "", nickname: "나", characterType: "", characterImageUrl: nil), + option: BattlePerspectiveOption(optionId: optionId, label: nil, title: "", stance: ""), + content: content, + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: true, + createdAt: Date() + ) + } } diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 63a30d08..a54669ba 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -21,10 +21,12 @@ public struct CommentFeature { @ObservableState public struct State: Equatable { public var battleId: Int + public var perspectiveId: Int? public var title: String public var voteSummary: VoteSummary public var isLoadingStats: Bool = false public var isLoadingComments: Bool = false + public var isSubmitting: Bool = false public var nextCursor: String? public var hasNext: Bool = false public var selectedFilter: CommentFilter = .all @@ -47,15 +49,19 @@ public struct CommentFeature { public var isSendEnabled: Bool { !commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSubmitting + && perspectiveId != nil } public init( battleId: Int = 0, + perspectiveId: Int? = nil, title: String = "원가 18만 원 명품은 사기다", voteSummary: VoteSummary = .mock, comments: [CommentItem] = CommentItem.mocks ) { self.battleId = battleId + self.perspectiveId = perspectiveId self.title = title self.voteSummary = voteSummary self.comments = comments @@ -91,12 +97,14 @@ public struct CommentFeature { case fetchVoteStats case fetchPerspectives(reset: Bool) case toggleLike(commentId: Int, currentlyLiked: Bool) + case createComment(perspectiveId: Int, content: String) } public enum InnerAction: Equatable { case voteStatsResponse(Result) case perspectivesResponse(Result, reset: Bool) case likeResponse(Result) + case createCommentResponse(Result) } @CasePathable @@ -113,10 +121,12 @@ public struct CommentFeature { case fetchVoteStats case fetchPerspectives case toggleLike + case createComment } @Dependency(\.battleRepository) private var battleRepository @Dependency(\.commentRepository) private var commentRepository + @Dependency(\.perspectiveRepository) private var perspectiveRepository public var body: some Reducer { BindingReducer() @@ -212,22 +222,13 @@ extension CommentFeature { case .sendTapped: let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return .none } - state.comments.insert( - CommentItem( - author: "나", - timeAgo: "방금 전", - option: .a, - content: text, - replyCount: 0, - likeCount: 0, - createdOrder: (state.comments.map(\.createdOrder).max() ?? 0) + 1 - ), - at: 0 - ) + guard !text.isEmpty, + let pid = state.perspectiveId, + !state.isSubmitting + else { return .none } + state.isSubmitting = true state.commentText = "" - state.reportTargetCommentID = nil - return .none + return .send(.async(.createComment(perspectiveId: pid, content: text))) } } @@ -313,6 +314,16 @@ extension CommentFeature { return await send(.inner(.likeResponse(result))) } .cancellable(id: CancelID.toggleLike, cancelInFlight: false) + + case let .createComment(perspectiveId, content): + return .run { [repository = perspectiveRepository] send in + let result = await Result { + try await repository.createComment(perspectiveId: perspectiveId, content: content) + } + .mapError(CommentError.from) + return await send(.inner(.createCommentResponse(result))) + } + .cancellable(id: CancelID.createComment, cancelInFlight: false) } } @@ -357,6 +368,16 @@ extension CommentFeature { Log.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)") } return .none + + case let .createCommentResponse(result): + state.isSubmitting = false + switch result { + case .success: + return .send(.async(.fetchPerspectives(reset: true))) + case let .failure(error): + Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") + return .none + } } } diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 40dbac43..6b65fb9a 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -185,34 +185,49 @@ private extension CommentView { func filterButton(_ filter: CommentFilter) -> some View { let isSelected = store.selectedFilter == filter + let title: String = switch filter { + case .all: "전체" + case .optionA: store.voteSummary.optionA.title + case .optionB: store.voteSummary.optionB.title + } return Button { send(.filterTapped(filter)) } label: { - Text(filter.title) + Text(title) .pretendardFont(family: .Medium, size: 14) .foregroundStyle(isSelected ? .primary500 : .neutral300) .frame(maxWidth: .infinity) .padding(.horizontal, 12) .padding(.vertical, 8) .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.neutral200) + .frame(height: isSelected ? 2.5 : 1) + } } .buttonStyle(.plain) } func sortButton(_ sort: CommentSort) -> some View { let isSelected = store.selectedSort == sort + let selectedFill = Color.primary500 + let unselectedFill = Color.primary50 return Button { send(.sortTapped(sort)) } label: { Text(sort.title) .pretendardFont(family: .Medium, size: 13) .foregroundStyle(isSelected ? .beige50 : .primary500) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(isSelected ? Color.primary400 : Color.beige50, in: RoundedRectangle(cornerRadius: 2)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + isSelected ? selectedFill : unselectedFill, + in: RoundedRectangle(cornerRadius: 2) + ) .overlay { RoundedRectangle(cornerRadius: 2) - .stroke(isSelected ? Color.primary400 : Color.primary50, lineWidth: 1) + .stroke(Color.primary500, lineWidth: isSelected ? 0 : 1) } } .buttonStyle(.plain) From 6a8d763af8aa597f069e908c3ab9123fe16b9a1b Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 02:11:13 +0900 Subject: [PATCH 12/42] =?UTF-8?q?style:=20=EB=8C=93=EA=B8=80=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20.pen=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=E2=80=94=20=EC=95=84=EB=B0=94=ED=83=80=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20/=20=EC=98=B5=EC=85=98=20=EB=B1=83?= =?UTF-8?q?=EC=A7=80=20=EB=8F=99=EC=A0=81=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentItem.authorImageURL / optionLabel 필드 추가 - BattlePerspective → CommentItem 매핑 시 user.characterImageUrl + option.title 전달 - CommentView 댓글 카드 - 아바타: 첫 글자 텍스트 → KFImage(authorImageURL) + Circle stroke beige600 (URL 없으면 기존 fallback 사용) - 옵션 라벨 뱃지: "A 변기는 변기다" → "변기는 변기다" 만 (.pen Z3egi 매칭) - 시간 폰트 10pt → 12pt Medium (.pen `nI8bv` 매칭) - Kingfisher import 추가 (Chat 모듈) --- .../Comment/Reducer/CommentFeature.swift | 8 ++++ .../Sources/Comment/View/CommentView.swift | 43 +++++++++++++------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index a54669ba..96e4dde1 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -492,8 +492,10 @@ public struct CommentItem: Equatable, Identifiable { public let id: UUID public var perspectiveId: Int? public var author: String + public var authorImageURL: String? public var timeAgo: String public var option: CommentOption + public var optionLabel: String? public var content: String public var replyCount: Int public var likeCount: Int @@ -504,8 +506,10 @@ public struct CommentItem: Equatable, Identifiable { id: UUID = UUID(), perspectiveId: Int? = nil, author: String, + authorImageURL: String? = nil, timeAgo: String, option: CommentOption, + optionLabel: String? = nil, content: String, replyCount: Int, likeCount: Int, @@ -515,8 +519,10 @@ public struct CommentItem: Equatable, Identifiable { self.id = id self.perspectiveId = perspectiveId self.author = author + self.authorImageURL = authorImageURL self.timeAgo = timeAgo self.option = option + self.optionLabel = optionLabel self.content = content self.replyCount = replyCount self.likeCount = likeCount @@ -531,8 +537,10 @@ public struct CommentItem: Equatable, Identifiable { id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), perspectiveId: item.perspectiveId, author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, timeAgo: Self.relativeTimeString(from: item.createdAt), option: optionFallback, + optionLabel: item.option.title, content: item.content, replyCount: item.commentCount, likeCount: item.likeCount, diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 6b65fb9a..b8e4e4c9 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Kingfisher @ViewAction(for: CommentFeature.self) public struct CommentView: View { @@ -275,14 +276,7 @@ private extension CommentView { func commentHeader(_ comment: CommentItem) -> some View { HStack(alignment: .top, spacing: 6) { - Circle() - .fill(.beige600) - .frame(width: 36, height: 36) - .overlay { - Text(String(comment.author.prefix(1))) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.primary500) - } + avatar(urlString: comment.authorImageURL, fallback: comment.author) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -292,11 +286,11 @@ private extension CommentView { .lineLimit(1) Text(comment.timeAgo) - .pretendardFont(family: .SemiBold, size: 10) + .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) } - optionBadge(comment.option) + optionBadge(comment) } Spacer() @@ -311,9 +305,10 @@ private extension CommentView { } } - func optionBadge(_ option: CommentOption) -> some View { - let summary = option == .a ? store.voteSummary.optionA : store.voteSummary.optionB - return Text("\(option.label) \(summary.title)") + func optionBadge(_ comment: CommentItem) -> some View { + let summary = comment.option == .a ? store.voteSummary.optionA : store.voteSummary.optionB + let label = comment.optionLabel ?? summary.title + return Text(label) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.primary500) .padding(.horizontal, 4) @@ -357,6 +352,28 @@ private extension CommentView { .pretendardFont(family: .Medium, size: 12) } } + + @ViewBuilder + func avatar(urlString: String?, fallback: String) -> some View { + if let urlString, let url = URL(string: urlString) { + KFImage(url) + .placeholder { Color.beige600 } + .resizable() + .scaledToFill() + .frame(width: 36, height: 36) + .clipShape(Circle()) + .overlay(Circle().stroke(.beige600, lineWidth: 1)) + } else { + Circle() + .fill(.beige600) + .frame(width: 36, height: 36) + .overlay { + Text(String(fallback.prefix(1))) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + } + } + } } // MARK: - Input From 42043b11a460c00029dae495942876b589917e0f Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 04:22:45 +0900 Subject: [PATCH 13/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20=ED=8C=9D=EC=97=85=20.pen=20byHAR=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=AF=B8=EB=9F=AC=20+=20sort=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=ED=94=BD=EC=8A=A4=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomConfirmationPopupView.reportContent .pen byHAR 매칭 - 헤더 "신고사유" 16pt SemiBold .primary800 (#653226) - 사유 5개 라디오 grid (2-column) - 좌: 영리목적/홍보성 · 음란성/선정성 · 기타 - 우: 같은 내용 반복 게시 · 욕설/인신공격 - Ellipse 20x20 stroke .gray200 (#b0afae) 3pt + 선택 시 inner Circle .primary500 - 버튼 horizontal: 신고하기 (.secondary50 fill / .primary800 text) / 뒤로가기 (.primary500 fill / .secondary50 text) - frame 343 cornerRadius 6 .beige500 (#f5f1ea) + border .primary500 1.5pt - ReportReason enum (commercial/repeat_/explicit/insult/other) 추가 - CommentView.sortButton 간격 0 → 8pt (.pen sort frame gap 매칭) --- .../Sources/Comment/View/CommentView.swift | 2 +- .../CustomConfirmationPopupView.swift | 115 ++++++++++++++++-- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index b8e4e4c9..748ab00e 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -173,7 +173,7 @@ private extension CommentView { } .frame(maxWidth: .infinity) - HStack(spacing: 0) { + HStack(spacing: 8) { sortButton(.popular) sortButton(.latest) Spacer() diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 37899b0f..c42a6f74 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -177,21 +177,112 @@ struct CustomConfirmationPopup: View { .onTapGesture {} } + @State private var selectedReason: ReportReason? + private var reportContent: some View { - Button(action: onConfirm) { - HStack(spacing: 4) { - Image(systemName: "light.beacon.max") - .font(.system(size: 15, weight: .regular)) - .frame(width: 24, height: 24) - - Text(confirmTitle) - .pretendardFont(family: .Medium, size: 13) + VStack(spacing: 16) { + Text("신고사유") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + ForEach(ReportReason.leftColumn) { reason in + reasonRow(reason) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 16) { + ForEach(ReportReason.rightColumn) { reason in + reasonRow(reason) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 20) + + HStack(spacing: 10) { + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + .disabled(selectedReason == nil) + .opacity(selectedReason == nil ? 0.5 : 1) + + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) } - .foregroundStyle(.beige50) - .frame(width: 70, height: 34) - .background(.primary500, in: Capsule()) } - .buttonStyle(.plain) + .padding(.top, 20) + .frame(width: 343) + .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.primary500, lineWidth: 1.5) + ) .onTapGesture {} } + + @ViewBuilder + private func reasonRow(_ reason: ReportReason) -> some View { + Button { + selectedReason = reason + } label: { + HStack(spacing: 6) { + ZStack { + Circle() + .stroke(.gray200, lineWidth: 3) + .frame(width: 20, height: 20) + if selectedReason == reason { + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + } + } + Text(reason.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral900) + } + } + .buttonStyle(.plain) + } +} + +public enum ReportReason: String, CaseIterable, Identifiable, Equatable { + case commercial + case repeat_ + case explicit + case insult + case other + + public var id: String { rawValue } + + public var title: String { + switch self { + case .commercial: "영리목적/홍보성" + case .repeat_: "같은 내용 반복 게시" + case .explicit: "음란성/선정성" + case .insult: "욕설/인신공격" + case .other: "기타" + } + } + + static let leftColumn: [ReportReason] = [.commercial, .explicit, .other] + static let rightColumn: [ReportReason] = [.repeat_, .insult] } From 4f12a13b3e92f44cf3fdbe02efc1558ee350f938 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 04:43:26 +0900 Subject: [PATCH 14/42] =?UTF-8?q?style:=20=EC=8B=A0=EA=B3=A0=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EB=86=92=EC=9D=B4=20236=20+=20ViewBuilder=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - byHAR 디자인 매칭: 전체 frame width 343 / height 236 - padding top 20 + 헤더 + gap 16 + 사유 grid (height 116) + gap 16 + 버튼 - 사유 grid: 내부 padding vertical 12 + .frame(height: 116) 명시 - reportContent 를 @ViewBuilder 서브뷰로 분리 - reportHeader / reasonGrid / reasonColumn / reasonRow / reasonRadio - reportButtons / reportSubmitButton / reportCancelButton --- .../CustomConfirmationPopupView.swift | 147 +++++++++++------- 1 file changed, 89 insertions(+), 58 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index c42a6f74..0d53806d 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -179,58 +179,15 @@ struct CustomConfirmationPopup: View { @State private var selectedReason: ReportReason? + @ViewBuilder private var reportContent: some View { VStack(spacing: 16) { - Text("신고사유") - .pretendardFont(family: .SemiBold, size: 16) - .foregroundStyle(.primary800) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - HStack(alignment: .top, spacing: 0) { - VStack(alignment: .leading, spacing: 16) { - ForEach(ReportReason.leftColumn) { reason in - reasonRow(reason) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - VStack(alignment: .leading, spacing: 16) { - ForEach(ReportReason.rightColumn) { reason in - reasonRow(reason) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 20) - - HStack(spacing: 10) { - Button(action: onConfirm) { - Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) - .pretendardFont(family: .Medium, size: 14) - .foregroundStyle(.primary800) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.secondary50, in: Rectangle()) - } - .buttonStyle(.plain) - .disabled(selectedReason == nil) - .opacity(selectedReason == nil ? 0.5 : 1) - - Button(action: onCancel) { - Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) - .pretendardFont(family: .Medium, size: 14) - .foregroundStyle(.secondary50) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(.primary500, in: Rectangle()) - } - .buttonStyle(.plain) - } + reportHeader + reasonGrid + reportButtons } .padding(.top, 20) - .frame(width: 343) + .frame(width: 343, height: 236) .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) @@ -239,22 +196,46 @@ struct CustomConfirmationPopup: View { .onTapGesture {} } + @ViewBuilder + private var reportHeader: some View { + Text("신고사유") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + + @ViewBuilder + private var reasonGrid: some View { + HStack(alignment: .top, spacing: 0) { + reasonColumn(ReportReason.leftColumn) + reasonColumn(ReportReason.rightColumn, fillSpacer: true) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(height: 116) + } + + @ViewBuilder + private func reasonColumn(_ reasons: [ReportReason], fillSpacer: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(reasons) { reason in + reasonRow(reason) + } + if fillSpacer { + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + @ViewBuilder private func reasonRow(_ reason: ReportReason) -> some View { Button { selectedReason = reason } label: { HStack(spacing: 6) { - ZStack { - Circle() - .stroke(.gray200, lineWidth: 3) - .frame(width: 20, height: 20) - if selectedReason == reason { - Circle() - .fill(.primary500) - .frame(width: 10, height: 10) - } - } + reasonRadio(isSelected: selectedReason == reason) Text(reason.title) .pretendardFont(family: .Medium, size: 14) .foregroundStyle(.neutral900) @@ -262,6 +243,56 @@ struct CustomConfirmationPopup: View { } .buttonStyle(.plain) } + + @ViewBuilder + private func reasonRadio(isSelected: Bool) -> some View { + ZStack { + Circle() + .stroke(.gray200, lineWidth: 3) + .frame(width: 20, height: 20) + if isSelected { + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + } + } + } + + @ViewBuilder + private var reportButtons: some View { + HStack(spacing: 10) { + reportSubmitButton + reportCancelButton + } + } + + @ViewBuilder + private var reportSubmitButton: some View { + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + .disabled(selectedReason == nil) + .opacity(selectedReason == nil ? 0.5 : 1) + } + + @ViewBuilder + private var reportCancelButton: some View { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } } public enum ReportReason: String, CaseIterable, Identifiable, Equatable { From c3beb938009d6708263f59fd3a973e735bd23a5a Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 04:52:15 +0900 Subject: [PATCH 15/42] =?UTF-8?q?refactor:=20CommentView=20=EC=A0=84=20sub?= =?UTF-8?q?-view=20ViewBuilder=20+=20private=20func=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20+=20=EB=B0=B0=ED=8B=80=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=ED=8B=80=20=EB=8F=99=EC=A0=81=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md ViewBuilder 규칙(95~196) 준수 - 모든 sub-view 를 @ViewBuilder private func 으로 분리 - navigationBar/summarySection/changeBadge/voteSide/voteProgress/avatarLabel - filterSection/filterTabs/sortRow/filterButton/sortButton - commentList/commentCard/commentBody/commentHeader/commentAuthorBlock - reportButton/optionBadge/commentActions/moreButton/replyCountButton - likeButton/actionLabel/avatar - inputBar/inputTextBox/sendButton - CommentFeature: fetchBattle 추가 - AsyncAction.fetchBattle / InnerAction.battleResponse / CancelID.fetchBattle - onAppear merge 에 fetchBattle 추가 - 응답 성공 시 state.title = detail.battleInfo.title - State.init default title "" + voteSummary .mock (서버 응답 도착하면 덮어씀) --- .../Comment/Reducer/CommentFeature.swift | 34 +- .../Sources/Comment/View/CommentView.swift | 294 +++++++++++------- 2 files changed, 208 insertions(+), 120 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 96e4dde1..11616680 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -56,9 +56,9 @@ public struct CommentFeature { public init( battleId: Int = 0, perspectiveId: Int? = nil, - title: String = "원가 18만 원 명품은 사기다", + title: String = "", voteSummary: VoteSummary = .mock, - comments: [CommentItem] = CommentItem.mocks + comments: [CommentItem] = [] ) { self.battleId = battleId self.perspectiveId = perspectiveId @@ -94,6 +94,7 @@ public struct CommentFeature { } public enum AsyncAction: Equatable { + case fetchBattle case fetchVoteStats case fetchPerspectives(reset: Bool) case toggleLike(commentId: Int, currentlyLiked: Bool) @@ -101,6 +102,7 @@ public struct CommentFeature { } public enum InnerAction: Equatable { + case battleResponse(Result) case voteStatsResponse(Result) case perspectivesResponse(Result, reset: Bool) case likeResponse(Result) @@ -118,6 +120,7 @@ public struct CommentFeature { } nonisolated enum CancelID: Hashable { + case fetchBattle case fetchVoteStats case fetchPerspectives case toggleLike @@ -171,6 +174,7 @@ extension CommentFeature { switch action { case .onAppear: return .merge( + .send(.async(.fetchBattle)), .send(.async(.fetchVoteStats)), .send(.async(.fetchPerspectives(reset: true))) ) @@ -268,6 +272,17 @@ extension CommentFeature { action: AsyncAction ) -> Effect { switch action { + case .fetchBattle: + let battleId = state.battleId + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchBattle(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.battleResponse(result))) + } + .cancellable(id: CancelID.fetchBattle, cancelInFlight: true) + case .fetchVoteStats: state.isLoadingStats = true let battleId = state.battleId @@ -332,6 +347,15 @@ extension CommentFeature { action: InnerAction ) -> Effect { switch action { + case let .battleResponse(result): + switch result { + case let .success(detail): + state.title = detail.battleInfo.title + case let .failure(error): + Log.error("[CommentFeature] fetchBattle failed: \(error.localizedDescription)") + } + return .none + case let .voteStatsResponse(result): state.isLoadingStats = false switch result { @@ -467,6 +491,12 @@ public struct VoteSummary: Equatable { optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) ) + + public static let empty = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "", representative: "", percentage: 0), + optionB: .init(label: "B", title: "", representative: "", percentage: 0) + ) } public struct VoteOptionSummary: Equatable { diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 748ab00e..ae5e29df 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -24,17 +24,17 @@ public struct CommentView: View { public var body: some View { VStack(spacing: 0) { - navigationBar + navigationBar() ScrollView(showsIndicators: false) { VStack(spacing: 0) { - summarySection - filterSection - commentList + summarySection() + filterSection() + commentList() } .padding(.bottom, 16) } .scrollDismissesKeyboard(.interactively) - inputBar + inputBar() } .background(Color.beige200.ignoresSafeArea()) .contentShape(Rectangle()) @@ -57,7 +57,8 @@ public struct CommentView: View { // MARK: - Navigation private extension CommentView { - var navigationBar: some View { + @ViewBuilder + func navigationBar() -> some View { HStack { Button { send(.backButtonTapped) } label: { Image(systemName: "chevron.left") @@ -92,21 +93,14 @@ private extension CommentView { // MARK: - Summary private extension CommentView { - var summarySection: some View { + @ViewBuilder + func summarySection() -> some View { VStack(spacing: 12) { - HStack { - Text(store.voteSummary.changeBadgeTitle) - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.primary500) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) - Spacer() - } + changeBadge() HStack(alignment: .center, spacing: 12) { voteSide(store.voteSummary.optionA, alignment: .leading) - voteProgress + voteProgress() voteSide(store.voteSummary.optionB, alignment: .trailing) } } @@ -115,6 +109,20 @@ private extension CommentView { .background(.beige50) } + @ViewBuilder + func changeBadge() -> some View { + HStack { + Text(store.voteSummary.changeBadgeTitle) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.primary500) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + Spacer() + } + } + + @ViewBuilder func voteSide(_ option: VoteOptionSummary, alignment: HorizontalAlignment) -> some View { VStack(alignment: alignment, spacing: 6) { avatarLabel(option.representative) @@ -125,7 +133,8 @@ private extension CommentView { .frame(width: 52, alignment: alignment == .leading ? .leading : .trailing) } - var voteProgress: some View { + @ViewBuilder + func voteProgress() -> some View { GeometryReader { proxy in let leftWidth = proxy.size.width * store.voteSummary.optionA.percentage ZStack(alignment: .leading) { @@ -142,6 +151,7 @@ private extension CommentView { .frame(height: 6) } + @ViewBuilder func avatarLabel(_ name: String) -> some View { VStack(spacing: 4) { Circle() @@ -164,26 +174,37 @@ private extension CommentView { // MARK: - Filters private extension CommentView { - var filterSection: some View { + @ViewBuilder + func filterSection() -> some View { VStack(spacing: 12) { - HStack(spacing: 0) { - ForEach(CommentFilter.allCases, id: \.self) { filter in - filterButton(filter) - } - } - .frame(maxWidth: .infinity) - - HStack(spacing: 8) { - sortButton(.popular) - sortButton(.latest) - Spacer() - } - .padding(.horizontal, 16) + filterTabs() + sortRow() } .padding(.top, 14) .padding(.bottom, 12) } + @ViewBuilder + func filterTabs() -> some View { + HStack(spacing: 0) { + ForEach(CommentFilter.allCases, id: \.self) { filter in + filterButton(filter) + } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func sortRow() -> some View { + HStack(spacing: 8) { + sortButton(.popular) + sortButton(.latest) + Spacer() + } + .padding(.horizontal, 16) + } + + @ViewBuilder func filterButton(_ filter: CommentFilter) -> some View { let isSelected = store.selectedFilter == filter let title: String = switch filter { @@ -191,7 +212,7 @@ private extension CommentView { case .optionA: store.voteSummary.optionA.title case .optionB: store.voteSummary.optionB.title } - return Button { + Button { send(.filterTapped(filter)) } label: { Text(title) @@ -210,11 +231,10 @@ private extension CommentView { .buttonStyle(.plain) } + @ViewBuilder func sortButton(_ sort: CommentSort) -> some View { let isSelected = store.selectedSort == sort - let selectedFill = Color.primary500 - let unselectedFill = Color.primary50 - return Button { + Button { send(.sortTapped(sort)) } label: { Text(sort.title) @@ -223,7 +243,7 @@ private extension CommentView { .padding(.horizontal, 12) .padding(.vertical, 6) .background( - isSelected ? selectedFill : unselectedFill, + isSelected ? Color.primary500 : Color.primary50, in: RoundedRectangle(cornerRadius: 2) ) .overlay { @@ -238,7 +258,8 @@ private extension CommentView { // MARK: - Comment List private extension CommentView { - var commentList: some View { + @ViewBuilder + func commentList() -> some View { VStack(spacing: 12) { ForEach(store.filteredComments) { comment in commentCard(comment) @@ -248,21 +269,11 @@ private extension CommentView { .padding(.bottom, 24) } + @ViewBuilder func commentCard(_ comment: CommentItem) -> some View { VStack(alignment: .leading, spacing: 8) { commentHeader(comment) - - Button { send(.replyTapped(comment.id)) } label: { - Text(comment.content) - .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral400) - .lineSpacing(13 * 0.4) - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - + commentBody(comment) commentActions(comment) } .padding(12) @@ -274,41 +285,64 @@ private extension CommentView { } } + @ViewBuilder + func commentBody(_ comment: CommentItem) -> some View { + Button { send(.replyTapped(comment.id)) } label: { + Text(comment.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + @ViewBuilder func commentHeader(_ comment: CommentItem) -> some View { HStack(alignment: .top, spacing: 6) { avatar(urlString: comment.authorImageURL, fallback: comment.author) + commentAuthorBlock(comment) + Spacer() + reportButton(commentId: comment.id) + } + } - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(comment.author) - .pretendardFont(family: .Medium, size: 14) - .foregroundStyle(.neutral500) - .lineLimit(1) - - Text(comment.timeAgo) - .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral300) - } - - optionBadge(comment) + @ViewBuilder + func commentAuthorBlock(_ comment: CommentItem) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(comment.author) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .lineLimit(1) + + Text(comment.timeAgo) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) } - Spacer() + optionBadge(comment) + } + } - Button { send(.reportButtonTapped(comment.id)) } label: { - Image(systemName: "ellipsis") - .font(.system(size: 18, weight: .regular)) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .foregroundStyle(.neutral300) + @ViewBuilder + func reportButton(commentId: UUID) -> some View { + Button { send(.reportButtonTapped(commentId)) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) } + .buttonStyle(.plain) + .foregroundStyle(.neutral300) } + @ViewBuilder func optionBadge(_ comment: CommentItem) -> some View { let summary = comment.option == .a ? store.voteSummary.optionA : store.voteSummary.optionB let label = comment.optionLabel ?? summary.title - return Text(label) + Text(label) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.primary500) .padding(.horizontal, 4) @@ -316,33 +350,47 @@ private extension CommentView { .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) } + @ViewBuilder func commentActions(_ comment: CommentItem) -> some View { HStack(spacing: 12) { - Button { send(.moreTapped(comment.id)) } label: { - Text("더보기") - .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral300) - } - .buttonStyle(.plain) - + moreButton(commentId: comment.id) Spacer() + replyCountButton(comment) + likeButton(comment) + } + .foregroundStyle(.neutral300) + } - Button { send(.replyTapped(comment.id)) } label: { - actionLabel(systemName: "message", text: "\(comment.replyCount)") - } - .buttonStyle(.plain) + @ViewBuilder + func moreButton(commentId: UUID) -> some View { + Button { send(.moreTapped(commentId)) } label: { + Text("더보기") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + } + .buttonStyle(.plain) + } - Button { send(.likeTapped(comment.id)) } label: { - actionLabel( - systemName: comment.isLiked ? "heart.fill" : "heart", - text: formattedCount(comment.likeCount) - ) - } - .buttonStyle(.plain) + @ViewBuilder + func replyCountButton(_ comment: CommentItem) -> some View { + Button { send(.replyTapped(comment.id)) } label: { + actionLabel(systemName: "message", text: "\(comment.replyCount)") } - .foregroundStyle(.neutral300) + .buttonStyle(.plain) } + @ViewBuilder + func likeButton(_ comment: CommentItem) -> some View { + Button { send(.likeTapped(comment.id)) } label: { + actionLabel( + systemName: comment.isLiked ? "heart.fill" : "heart", + text: formattedCount(comment.likeCount) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder func actionLabel(systemName: String, text: String) -> some View { HStack(spacing: 4) { Image(systemName: systemName) @@ -379,35 +427,12 @@ private extension CommentView { // MARK: - Input private extension CommentView { - var inputBar: some View { + @ViewBuilder + func inputBar() -> some View { VStack(spacing: 8) { HStack(alignment: .bottom, spacing: 8) { - VStack(alignment: .leading, spacing: 6) { - TextField("댓글을 입력해주세요", text: $store.commentText, axis: .vertical) - .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral400) - .lineLimit(1 ... 3) - .focused($isCommentFocused) - - Text("\(store.commentText.count)/200") - .pretendardFont(family: .SemiBold, size: 10) - .foregroundStyle(.neutral400) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(.beige50) - - Button { send(.sendTapped) } label: { - Image(systemName: "paperplane.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.beige50) - .frame(width: 36, height: 36) - .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) - } - .buttonStyle(.plain) - .disabled(!store.isSendEnabled) + inputTextBox() + sendButton() } } .padding(.top, 12) @@ -421,6 +446,39 @@ private extension CommentView { .frame(height: 1) } } + + @ViewBuilder + func inputTextBox() -> some View { + VStack(alignment: .leading, spacing: 6) { + TextField("댓글을 입력해주세요", text: $store.commentText, axis: .vertical) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineLimit(1 ... 3) + .focused($isCommentFocused) + + Text("\(store.commentText.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral400) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.beige50) + } + + @ViewBuilder + func sendButton() -> some View { + Button { send(.sendTapped) } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + .frame(width: 36, height: 36) + .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!store.isSendEnabled) + } } // MARK: - Format From fecc0b7504b4195b1e6531631ae47e1f32726c63 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 05:00:50 +0900 Subject: [PATCH 16/42] =?UTF-8?q?refactor:=20Comment=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20Entity=20=EB=AA=A8=EB=93=88=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentItem 을 Entity/Sources/Home/Comment.swift 로 이동 - BattlePerspective→CommentItem 매핑 init / deterministicUUID / relativeTimeString 포함 - 사용처 없는 mocks 정적 배열 제거 - VoteSummary / VoteOptionSummary / CommentFilter / CommentSort 신규 파일 Entity/Sources/Home/VoteSummary.swift 로 이동 - CommentReplyFeature: replies 기본값 mock fallback → 빈 배열 - CommentReplyView Preview 제거 (mockdata 의존 끊기) - AGENTS.md ViewBuilder 규칙 강화: 모든 sub-view 는 @ViewBuilder + private func 단일 뷰여도 var 형태 금지 — 호출부 일관성 확보 --- AGENTS.md | 70 +++++-- .../Domain/Entity/Sources/Home/Comment.swift | 76 +++++++ .../Entity/Sources/Home/VoteSummary.swift | 97 +++++++++ .../Comment/Reducer/CommentFeature.swift | 198 ------------------ .../Reducer/CommentReplyFeature.swift | 2 +- .../CommentReply/View/CommentReplyView.swift | 13 -- 6 files changed, 231 insertions(+), 225 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Home/VoteSummary.swift diff --git a/AGENTS.md b/AGENTS.md index 119f4dbc..2c7d0213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,12 +148,14 @@ public var body: some View { - 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용) - 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출 -#### 🧱 `@ViewBuilder` 함수 vs `var` — 자식 개수 / 분기 유무로 결정 +#### 🧱 `@ViewBuilder` + `private func` — 모든 sub-view 는 함수 형태 (필수) -분리한 sub-view 의 선언 형태는 **자식 개수와 분기 유무** 로만 정한다. +분리한 sub-view 는 **자식 개수 / 분기 유무와 상관없이 무조건 `@ViewBuilder` + `private func` 형태** 로 통일한다. + +`private var ...: some View` 형태는 **금지** — 모든 sub-view 는 호출부에서 일관되게 `()` 호출이 보이도록 함수 형태로만 작성한다. ```swift -// ✅ 다중 자식을 감싸거나 if/else · switch 분기가 있으면 `@ViewBuilder` + 함수 +// ✅ 다중 자식 / 분기 / 반복 @ViewBuilder private func hotBattlesSection() -> some View { VStack(alignment: .leading, spacing: 12) { @@ -175,8 +177,9 @@ private func thumbnail(url: URL?) -> some View { } } -// ✅ 단일 뷰만 반환하면 `private var` 형태 -private var primaryButton: some View { +// ✅ 단일 뷰여도 함수 형태로 +@ViewBuilder +private func primaryButton() -> some View { CustomButton( action: { send(.primaryButtonTapped) }, title: "사전 투표하기", @@ -185,17 +188,16 @@ private var primaryButton: some View { ) } -// ❌ 금지 — VStack 으로 자식 여러 개 감싸는데 var 만 쓰는 경우 (분기 / 동적 children 추가 시 깨짐) -private var section: some View { - VStack { ... } // → @ViewBuilder + func 으로 가야 안전 -} +// ❌ 금지 — sub-view 를 var 로 선언 +private var primaryButton: some View { ... } +private var section: some View { VStack { ... } } ``` 규칙: -- **`@ViewBuilder` + `private func`** : VStack/HStack/ZStack 등으로 **자식 ≥ 2개** 를 감싸거나 `if` / `switch` / `ForEach` 같은 분기·반복이 있을 때 -- **`private var ...: some View`** : **단일 뷰** 1개만 반환할 때 (단순 wrapping · CTA 버튼 · 단일 Image 등) -- body 안에서 호출하는 sub-view 가 인자가 필요하면 함수, 없으면 var 가 우선 — 기준은 "자식 수 / 분기 유무" 가 먼저 -- 레퍼런스: `HomeView.hotBattlesSection`, `PreVoteView.primaryButton`, `HeroCardView.thumbnail` +- **모든 sub-view = `@ViewBuilder private func name() -> some View`** (단일 뷰 / 다중 자식 / 분기 무관) +- `private var ...: some View` 패턴 금지 — 호출부에서 `name` vs `name()` 형태 혼재되는 것을 방지 +- `body` 만 `var body: some View` 유지 (View 프로토콜 요구사항) +- body 안에서 호출은 항상 `()` 가 붙은 함수 형태로 — 가독성 통일 #### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용 @@ -368,6 +370,48 @@ public init( 레퍼런스: `HomeFeature.State`, attendance `ProfileFeature.State` +#### 📦 Feature 화면 모델 / Item / Filter / Sort 는 Entity 모듈에 정의 (필수) + +`XxxFeature.swift` 안에 `XxxItem` · `XxxSummary` · `XxxFilter` · `XxxSort` 같은 **화면용 모델 / 분기 enum** 을 직접 선언하지 않는다. 모두 `Domain/Entity` 모듈에 정의하고 Feature/View 에서는 `import Entity` 로 가져다 쓴다. + +```swift +// ✅ 올바른 패턴 — 화면 모델은 Entity 모듈에 정의 +// Projects/Domain/Entity/Sources/Home/Comment.swift +public struct CommentItem: Equatable, Identifiable { ... } +public enum CommentFilter: String, CaseIterable, Equatable { ... } +public enum CommentSort: String, CaseIterable, Equatable { ... } +public struct VoteSummary: Equatable { ... } + +// Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +import Entity + +@Reducer +public struct CommentFeature { + @ObservableState + public struct State: Equatable { + public var comments: [CommentItem] = [] + public var selectedFilter: CommentFilter = .all + public var selectedSort: CommentSort = .popular + public var voteSummary: VoteSummary = .empty + } +} + +// ❌ 금지 — Feature 파일 안에 모델을 같이 선언 +// CommentFeature.swift +public struct CommentItem: Equatable, Identifiable { ... } // ← Entity 로 이동 +public enum CommentFilter: ... { ... } // ← Entity 로 이동 +public struct VoteSummary: Equatable { ... } // ← Entity 로 이동 +``` + +규칙: +- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Comment.swift`, `Home/Battle.swift` 등) +- Feature 안에는 `State` / `Action` / `Reducer` / `CancelID` 같은 **TCA 컴포넌트만** 둔다 +- UI 분기용 enum (`CommentFilter`, `CommentSort`) 도 도메인 모델로 취급해 Entity 에 둔다 — 같은 도메인의 여러 화면에서 재사용 가능 +- 서버 응답 매핑 init (`init(item: BattlePerspective, order: Int)`) · 정적 mocks · `.empty` 팩토리도 모두 Entity 쪽에서 정의 +- 의존성 방향 유지: `Presentation → Domain ← Data` — Entity 는 SwiftUI/TCA/Network 비의존 (Foundation 만) + +레퍼런스: `Entity/Sources/Home/Comment.swift` (CommentItem, CommentFilter, CommentSort, CommentReplyItem, VoteSummary) + #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 `do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. diff --git a/Projects/Domain/Entity/Sources/Home/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment.swift index 2a618194..d3b69a35 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment.swift @@ -185,3 +185,79 @@ public enum CommentTab: Equatable, Hashable { } } } + +public struct CommentItem: Equatable, Identifiable { + public let id: UUID + public var perspectiveId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var optionLabel: String? + public var content: String + public var replyCount: Int + public var likeCount: Int + public var isLiked: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + perspectiveId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + optionLabel: String? = nil, + content: String, + replyCount: Int, + likeCount: Int, + isLiked: Bool = false, + createdOrder: Int + ) { + self.id = id + self.perspectiveId = perspectiveId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.optionLabel = optionLabel + self.content = content + self.replyCount = replyCount + self.likeCount = likeCount + self.isLiked = isLiked + self.createdOrder = createdOrder + } + + /// API 응답 BattlePerspective 를 화면 모델로 변환. + public init(item: BattlePerspective, order: Int) { + let optionFallback: CommentOption = item.option.label == "B" ? .b : .a + self.init( + id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + perspectiveId: item.perspectiveId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: optionFallback, + optionLabel: item.option.title, + content: item.content, + replyCount: item.commentCount, + likeCount: item.likeCount, + isLiked: item.isLiked, + createdOrder: order + ) + } + + private static func deterministicUUID(perspectiveId: Int) -> String { + let hex = String(format: "%012X", perspectiveId) + return "00000000-0000-0000-0000-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } +} diff --git a/Projects/Domain/Entity/Sources/Home/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/VoteSummary.swift new file mode 100644 index 00000000..9a3cb92c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/VoteSummary.swift @@ -0,0 +1,97 @@ +// +// VoteSummary.swift +// Entity +// +// 댓글 화면 상단 투표 통계 모델. +// + +import Foundation + +public struct VoteSummary: Equatable { + public var changeBadgeTitle: String + public var optionA: VoteOptionSummary + public var optionB: VoteOptionSummary + + public init( + changeBadgeTitle: String, + optionA: VoteOptionSummary, + optionB: VoteOptionSummary + ) { + self.changeBadgeTitle = changeBadgeTitle + self.optionA = optionA + self.optionB = optionB + } + + public static let mock = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), + optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) + ) + + public static let empty = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "", representative: "", percentage: 0), + optionB: .init(label: "B", title: "", representative: "", percentage: 0) + ) +} + +public struct VoteOptionSummary: Equatable { + public var label: String + public var title: String + public var representative: String + public var percentage: Double + + public init( + label: String, + title: String, + representative: String, + percentage: Double + ) { + self.label = label + self.title = title + self.representative = representative + self.percentage = percentage + } +} + +public enum CommentFilter: String, CaseIterable, Equatable { + case all + case optionA + case optionB + + public var title: String { + switch self { + case .all: "전체" + case .optionA: "A" + case .optionB: "B" + } + } + + /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. + public var queryLabel: String? { + switch self { + case .all: nil + case .optionA: "A" + case .optionB: "B" + } + } +} + +public enum CommentSort: String, CaseIterable, Equatable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + public var perspectiveSort: BattlePerspectiveSort { + switch self { + case .popular: .popular + case .latest: .latest + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 11616680..381e9ec4 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -428,201 +428,3 @@ extension CommentFeature { ) } } - -public enum CommentFilter: String, CaseIterable, Equatable { - case all - case optionA - case optionB - - public var title: String { - switch self { - case .all: "전체" - case .optionA: "A" - case .optionB: "B" - } - } - - /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. - public var queryLabel: String? { - switch self { - case .all: nil - case .optionA: "A" - case .optionB: "B" - } - } -} - -public enum CommentSort: String, CaseIterable, Equatable { - case popular - case latest - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } - - public var perspectiveSort: BattlePerspectiveSort { - switch self { - case .popular: .popular - case .latest: .latest - } - } -} - -public struct VoteSummary: Equatable { - public var changeBadgeTitle: String - public var optionA: VoteOptionSummary - public var optionB: VoteOptionSummary - - public init( - changeBadgeTitle: String, - optionA: VoteOptionSummary, - optionB: VoteOptionSummary - ) { - self.changeBadgeTitle = changeBadgeTitle - self.optionA = optionA - self.optionB = optionB - } - - public static let mock = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), - optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) - ) - - public static let empty = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "", representative: "", percentage: 0), - optionB: .init(label: "B", title: "", representative: "", percentage: 0) - ) -} - -public struct VoteOptionSummary: Equatable { - public var label: String - public var title: String - public var representative: String - public var percentage: Double - - public init( - label: String, - title: String, - representative: String, - percentage: Double - ) { - self.label = label - self.title = title - self.representative = representative - self.percentage = percentage - } -} - -public struct CommentItem: Equatable, Identifiable { - public let id: UUID - public var perspectiveId: Int? - public var author: String - public var authorImageURL: String? - public var timeAgo: String - public var option: CommentOption - public var optionLabel: String? - public var content: String - public var replyCount: Int - public var likeCount: Int - public var isLiked: Bool - public var createdOrder: Int - - public init( - id: UUID = UUID(), - perspectiveId: Int? = nil, - author: String, - authorImageURL: String? = nil, - timeAgo: String, - option: CommentOption, - optionLabel: String? = nil, - content: String, - replyCount: Int, - likeCount: Int, - isLiked: Bool = false, - createdOrder: Int - ) { - self.id = id - self.perspectiveId = perspectiveId - self.author = author - self.authorImageURL = authorImageURL - self.timeAgo = timeAgo - self.option = option - self.optionLabel = optionLabel - self.content = content - self.replyCount = replyCount - self.likeCount = likeCount - self.isLiked = isLiked - self.createdOrder = createdOrder - } - - /// API 응답 BattlePerspective 를 화면 모델로 변환. - public init(item: BattlePerspective, order: Int) { - let optionFallback: CommentOption = item.option.label == "B" ? .b : .a - self.init( - id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), - perspectiveId: item.perspectiveId, - author: item.user.nickname, - authorImageURL: item.user.characterImageUrl, - timeAgo: Self.relativeTimeString(from: item.createdAt), - option: optionFallback, - optionLabel: item.option.title, - content: item.content, - replyCount: item.commentCount, - likeCount: item.likeCount, - isLiked: item.isLiked, - createdOrder: order - ) - } - - private static func deterministicUUID(perspectiveId: Int) -> String { - let hex = String(format: "%012X", perspectiveId) - return "00000000-0000-0000-0000-\(hex)" - } - - private static func relativeTimeString(from date: Date?) -> String { - guard let date else { return "방금 전" } - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "방금 전" } - if interval < 3600 { return "\(Int(interval / 60))분 전" } - if interval < 86400 { return "\(Int(interval / 3600))시간 전" } - return "\(Int(interval / 86400))일 전" - } - - public static let mocks: [CommentItem] = [ - .init( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), - author: "사유하는 사용자", - timeAgo: "2분 전", - option: .a, - content: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요. 네덜란드 사례를 보면 우려가 현실이 되고 있죠. 제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요.", - replyCount: 23, - likeCount: 1340, - createdOrder: 3 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), - author: "논쟁을 즐기는 사람", - timeAgo: "8분 전", - option: .b, - content: "맥락을 바꾸면 같은 사물도 전혀 다른 의미를 갖게 됩니다. 결국 예술은 물건의 가격보다 해석의 층위로 결정되는 것 같아요.", - replyCount: 8, - likeCount: 692, - createdOrder: 2 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000003") ?? UUID(), - author: "깊게 읽는 독자", - timeAgo: "15분 전", - option: .a, - content: "브랜드가 붙었다고 본질이 달라지는 건 아니라고 봅니다. 사용되는 기능과 재료가 같다면 사치재의 권위는 결국 합의된 환상에 가깝죠.", - replyCount: 4, - likeCount: 421, - createdOrder: 1 - ), - ] -} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index c4297ca1..ede954c2 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -45,7 +45,7 @@ public struct CommentReplyFeature { ) { self.perspectiveId = perspectiveId self.parentComment = parentComment - self.replies = replies ?? CommentReplyItem.mocks(for: parentComment.option) + self.replies = replies ?? [] } } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 9335ecf0..6ea9ca4a 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -294,16 +294,3 @@ private extension CommentReplyView { return formatter.string(from: NSNumber(value: count)) ?? "\(count)" } } - -#Preview { - CommentReplyView( - store: Store( - initialState: CommentReplyFeature.State( - perspectiveId: CommentItem.mocks[0].perspectiveId ?? 0, - parentComment: CommentItem.mocks[0] - ) - ) { - CommentReplyFeature() - } - ) -} From c7f1bb5464c2be15d7f7f513aca27cf0893b581e Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 05:06:25 +0900 Subject: [PATCH 17/42] =?UTF-8?q?fix:=20=EC=B5=9C=EC=A2=85=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=8C=9D=EC=97=85=20=EB=91=90=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=20=EA=B0=84=EA=B2=A9=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=E2=80=94=20.pen=20K7qq7=20=EB=A7=A4=EC=B9=AD=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finalVoteContent HStack spacing 10 → 0 두 버튼 (다시 들어볼래요 / 최종투표하기) 이 붙어있도록 변경 --- .../UI/Alert/CustomPopup/CustomConfirmationPopupView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 0d53806d..4c2140a5 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -142,7 +142,7 @@ struct CustomConfirmationPopup: View { .frame(maxWidth: .infinity) .padding(.horizontal, 20) - HStack(spacing: 10) { + HStack(spacing: 0) { Button(action: onCancel) { Text(cancelTitle) .pretendardFont(family: .Medium, size: 14) From a0286e0e756b2dac5c7bd5b0787e8fff1ec0fc75 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 05:22:19 +0900 Subject: [PATCH 18/42] =?UTF-8?q?refactor:=20Feature=20State=20init=20Atte?= =?UTF-8?q?ndance=5FiOS=20=ED=8C=A8=ED=84=B4=20=EC=9D=BC=EC=B9=98=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inline default + 외부 주입 인자만 init 파라미터로 노출 - CommentFeature.State.init(battleId:) — title/voteSummary/comments 등은 inline default - PreVoteFeature.State.init(battleId:, voteMode:) — battle 파라미터 제거 (internal state) - CommentReplyFeature.State.init(perspectiveId:, parentComment:) — replies 인자 제거 - PreVoteView Preview: .init() 만 호출 (.mock 의존 제거) - AGENTS.md: 파라미터 ≥ 2 시 멀티 라인 규칙 추가 --- AGENTS.md | 39 ++++++++++ .../Comment/Reducer/CommentFeature.swift | 20 ++---- .../Sources/Comment/View/CommentView.swift | 5 +- .../Reducer/CommentReplyFeature.swift | 6 +- .../CommentReply/View/CommentReplyView.swift | 20 +++--- .../Sources/Vote/Reducer/PreVoteFeature.swift | 8 +-- .../View/Components/PreVoteSkeletonView.swift | 28 ++++---- .../Chat/Sources/Vote/View/PreVoteView.swift | 18 ++--- .../Main/View/Components/QuizCardView.swift | 72 ++++++++++++++----- 9 files changed, 142 insertions(+), 74 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c7d0213..5c039f5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,45 @@ private var section: some View { VStack { ... } } - `body` 만 `var body: some View` 유지 (View 프로토콜 요구사항) - body 안에서 호출은 항상 `()` 가 붙은 함수 형태로 — 가독성 통일 +#### 📏 함수 시그니처 — 파라미터 2개 이상이면 멀티 라인 (필수) + +함수 파라미터가 **2개 이상이면 각 파라미터를 새 줄에** 풀어 쓴다. 인라인 한 줄 시그니처는 1-파라미터 이하에서만 허용. + +```swift +// ✅ 파라미터 2개 이상 — 멀티 라인 +@ViewBuilder +private func voteSide( + _ option: VoteOptionSummary, + alignment: HorizontalAlignment +) -> some View { + VStack(alignment: alignment, spacing: 6) { ... } +} + +@ViewBuilder +private func optionButton( + _ choice: Choice, + label: String, + desc: String, + isCorrect: Bool +) -> some View { ... } + +// ✅ 파라미터 0~1개 — 한 줄 OK +@ViewBuilder +private func navigationBar() -> some View { ... } + +@ViewBuilder +private func filterButton(_ filter: CommentFilter) -> some View { ... } + +// ❌ 금지 — 파라미터 2개 이상을 한 줄로 +private func voteSide(_ option: VoteOptionSummary, alignment: HorizontalAlignment) -> some View +private func resultBadge(isSelected: Bool, isCorrect: Bool) -> some View +``` + +규칙: +- View 함수 / Reducer 헬퍼 / 일반 메서드 / init 모두 동일 — 파라미터 ≥ 2 → 멀티 라인 +- 호출부 (call site) 도 동일 — `.init(a: x, b: y, c: z)` 처럼 인자 ≥ 2 면 멀티 라인 권장 (이미 대다수 코드 이 패턴) +- 닫는 `)` 와 `-> some View` 는 시그니처 끝줄에 함께 + #### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용 ```swift diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 381e9ec4..7e7032cb 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -20,10 +20,10 @@ public struct CommentFeature { @ObservableState public struct State: Equatable { - public var battleId: Int + public var battleId: Int = 0 public var perspectiveId: Int? - public var title: String - public var voteSummary: VoteSummary + public var title: String = "" + public var voteSummary: VoteSummary = .mock public var isLoadingStats: Bool = false public var isLoadingComments: Bool = false public var isSubmitting: Bool = false @@ -33,7 +33,7 @@ public struct CommentFeature { public var selectedSort: CommentSort = .popular public var reportTargetCommentID: UUID? @Presents public var customAlert: CustomAlertState? - public var comments: [CommentItem] + public var comments: [CommentItem] = [] public var commentText: String = "" public var filteredComments: [CommentItem] { @@ -53,18 +53,8 @@ public struct CommentFeature { && perspectiveId != nil } - public init( - battleId: Int = 0, - perspectiveId: Int? = nil, - title: String = "", - voteSummary: VoteSummary = .mock, - comments: [CommentItem] = [] - ) { + public init(battleId: Int = 0) { self.battleId = battleId - self.perspectiveId = perspectiveId - self.title = title - self.voteSummary = voteSummary - self.comments = comments } } diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index ae5e29df..0bc47df7 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -123,7 +123,10 @@ private extension CommentView { } @ViewBuilder - func voteSide(_ option: VoteOptionSummary, alignment: HorizontalAlignment) -> some View { + func voteSide( + _ option: VoteOptionSummary, + alignment: HorizontalAlignment + ) -> some View { VStack(alignment: alignment, spacing: 6) { avatarLabel(option.representative) Text(percentText(option.percentage)) diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index ede954c2..a907b282 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -26,7 +26,7 @@ public struct CommentReplyFeature { public struct State: Equatable { public var perspectiveId: Int public var parentComment: CommentItem - public var replies: [CommentReplyItem] + public var replies: [CommentReplyItem] = [] public var replyText: String = "" public var isLoadingDetail: Bool = false public var isLoadingReplies: Bool = false @@ -40,12 +40,10 @@ public struct CommentReplyFeature { public init( perspectiveId: Int, - parentComment: CommentItem, - replies: [CommentReplyItem]? = nil + parentComment: CommentItem ) { self.perspectiveId = perspectiveId self.parentComment = parentComment - self.replies = replies ?? [] } } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 6ea9ca4a..59dce217 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -20,16 +20,16 @@ public struct CommentReplyView: View { public var body: some View { VStack(spacing: 0) { - navigationBar + navigationBar() ScrollView(showsIndicators: false) { VStack(spacing: 0) { - parentCommentSection - replySection + parentCommentSection() + replySection() } .padding(.bottom, 16) } .scrollDismissesKeyboard(.interactively) - inputBar + inputBar() } .background(Color.beige200.ignoresSafeArea()) .contentShape(Rectangle()) @@ -45,7 +45,8 @@ public struct CommentReplyView: View { // MARK: - Navigation private extension CommentReplyView { - var navigationBar: some View { + @ViewBuilder + func navigationBar() -> some View { HStack { Button { send(.backButtonTapped) } label: { Image(systemName: "chevron.left") @@ -79,7 +80,8 @@ private extension CommentReplyView { // MARK: - Content private extension CommentReplyView { - var parentCommentSection: some View { + @ViewBuilder + func parentCommentSection() -> some View { VStack(spacing: 0) { commentCard( author: store.parentComment.author, @@ -103,7 +105,8 @@ private extension CommentReplyView { } } - var replySection: some View { + @ViewBuilder + func replySection() -> some View { VStack(alignment: .leading, spacing: 8) { Text("답글 \(store.replies.count)개") .pretendardFont(family: .SemiBold, size: 13) @@ -243,7 +246,8 @@ private extension CommentReplyView { // MARK: - Input private extension CommentReplyView { - var inputBar: some View { + @ViewBuilder + func inputBar() -> some View { HStack(alignment: .bottom, spacing: 8) { VStack(alignment: .leading, spacing: 6) { TextField("내 의견은 어쩌구 저쩌구", text: $store.replyText, axis: .vertical) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 431f990a..9cbecc57 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -30,8 +30,8 @@ public struct PreVoteFeature { public var isLoading: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? - public var battleId: Int - public var voteMode: VoteMode + public var battleId: Int = 0 + public var voteMode: VoteMode = .pre public var isPrimaryButtonEnabled: Bool { selectedOptionId != nil && !isSubmitting @@ -46,11 +46,9 @@ public struct PreVoteFeature { public init( battleId: Int = 0, - battle: PreVoteBattle? = nil, voteMode: VoteMode = .pre ) { self.battleId = battleId - self.battle = battle self.voteMode = voteMode } } @@ -309,7 +307,7 @@ extension PreVoteFeature { ) -> Effect { switch action { case .dismiss, .voteSubmitted: - return .none + .none } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift index e66d8110..7d3dd61a 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -13,10 +13,10 @@ import DesignSystem struct PreVoteSkeletonView: View { var body: some View { VStack(spacing: 0) { - hero - contentSection + hero() + contentSection() Spacer(minLength: 0) - ctaButton + ctaButton() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color.beige50.ignoresSafeArea()) @@ -27,7 +27,7 @@ struct PreVoteSkeletonView: View { private extension PreVoteSkeletonView { @ViewBuilder - var hero: some View { + func hero() -> some View { SkeletonView(cornerRadius: 6) .frame(height: 329) .overlay(alignment: .top) { @@ -42,19 +42,19 @@ private extension PreVoteSkeletonView { private extension PreVoteSkeletonView { @ViewBuilder - var contentSection: some View { + func contentSection() -> some View { VStack(alignment: .leading, spacing: 16) { - tagsRow - titleBlock - summaryBlock - optionsRow + tagsRow() + titleBlock() + summaryBlock() + optionsRow() } .padding(.horizontal, 16) .padding(.top, 24) } @ViewBuilder - var tagsRow: some View { + func tagsRow() -> some View { HStack(spacing: 8) { SkeletonView(cornerRadius: 6) .frame(width: 29, height: 17) @@ -64,19 +64,19 @@ private extension PreVoteSkeletonView { } @ViewBuilder - var titleBlock: some View { + func titleBlock() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 167, height: 68) } @ViewBuilder - var summaryBlock: some View { + func summaryBlock() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 235.5, height: 61.43) } @ViewBuilder - var optionsRow: some View { + func optionsRow() -> some View { ZStack { HStack(spacing: 8) { SkeletonView(cornerRadius: 6) @@ -94,7 +94,7 @@ private extension PreVoteSkeletonView { private extension PreVoteSkeletonView { @ViewBuilder - var ctaButton: some View { + func ctaButton() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 87, height: 24) .padding(.bottom, 40) diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 8d5b839f..8d3c8301 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -25,7 +25,7 @@ public struct PreVoteView: View { if shouldShowSkeleton { PreVoteSkeletonView() } else { - loadedContent + loadedContent() } } .background(Color.beige50.ignoresSafeArea()) @@ -34,14 +34,14 @@ public struct PreVoteView: View { .toolbar(.hidden, for: .tabBar) .overlay(alignment: .bottom) { if !shouldShowSkeleton { - primaryButton + primaryButton() .padding(.horizontal, Self.ctaHorizontalPadding) .padding(.bottom, Self.ctaBottomSpacing) } } .overlay(alignment: .top) { if !shouldShowSkeleton { - navigationBar + navigationBar() .background(Color.clear) .padding(.top, 12) .frame(maxWidth: .infinity) @@ -62,7 +62,7 @@ public struct PreVoteView: View { } @ViewBuilder - private var loadedContent: some View { + private func loadedContent() -> some View { if let battle = store.battle { GeometryReader { proxy in ZStack(alignment: .top) { @@ -126,7 +126,7 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var navigationBar: some View { + private func navigationBar() -> some View { HStack { Button { send(.backButtonTapped) } label: { Image(systemName: "chevron.left") @@ -239,7 +239,7 @@ extension PreVoteView { optionCard(battle.rightOption) } .frame(maxWidth: .infinity) - vsBadge + vsBadge() } } @@ -297,7 +297,7 @@ extension PreVoteView { } @ViewBuilder - private var vsBadge: some View { + private func vsBadge() -> some View { Text("VS") .pretendardFont(family: .Bold, size: 11) .foregroundStyle(.neutral800) @@ -311,7 +311,7 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var primaryButton: some View { + private func primaryButton() -> some View { CustomButton( action: { send(.primaryButtonTapped) }, title: store.primaryButtonTitle, @@ -323,7 +323,7 @@ extension PreVoteView { #Preview { PreVoteView( - store: Store(initialState: PreVoteFeature.State(battle: .mock)) { + store: Store(initialState: PreVoteFeature.State()) { PreVoteFeature() } ) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift index f1e17dd7..5f237b66 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift @@ -4,7 +4,7 @@ // // Created by Wonji Suh on 5/15/26. // -// Pencil .pen `Card/Quiz` — 단일 상태 (Property variant 없음). +// Pencil .pen `Card/Quiz` — 선택 전/후 (O 정답 · X 오답) 상태 미러. // import SwiftUI @@ -12,10 +12,17 @@ import SwiftUI import DesignSystem import Entity -/// "오늘의 Pické — 퀴즈" 카드. (선택/결과 상태 분리 없음 — .pen 디자인 단일) +/// "오늘의 Pické — 퀴즈" 카드. 옵션 탭 → 정답 비교 → O/X 결과 라벨 노출. struct QuizCardView: View { let question: QuizQuestion + @State private var selectedOption: Choice? + + enum Choice: Equatable { + case a + case b + } + var body: some View { VStack(alignment: .leading, spacing: 20) { header() @@ -59,26 +66,55 @@ struct QuizCardView: View { @ViewBuilder private func options() -> some View { HStack(spacing: 8) { - option(label: question.itemA, desc: question.itemADesc) - option(label: question.itemB, desc: question.itemBDesc) + optionButton(.a, label: question.itemA, desc: question.itemADesc, isCorrect: question.isCorrectA) + optionButton(.b, label: question.itemB, desc: question.itemBDesc, isCorrect: question.isCorrectB) } } @ViewBuilder - private func option(label: String, desc: String) -> some View { - VStack(spacing: 2) { - Text(label) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.neutral900) - Text(desc) - .pretendardFont(family: .Medium, size: 11) - .foregroundStyle(.neutral300) + private func optionButton( + _ choice: Choice, + label: String, + desc: String, + isCorrect: Bool + ) -> some View { + let isSelected = selectedOption == choice + let hasAnswered = selectedOption != nil + Button { + if selectedOption == nil { selectedOption = choice } + } label: { + VStack(spacing: 2) { + resultBadge(isSelected: isSelected, isCorrect: isCorrect) + Text(label) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + Text(desc) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral300) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige500, lineWidth: 1) + ) + .opacity(hasAnswered && !isSelected ? 0.5 : 1) + } + .buttonStyle(.plain) + .disabled(hasAnswered) + } + + @ViewBuilder + private func resultBadge( + isSelected: Bool, + isCorrect: Bool + ) -> some View { + if isSelected { + Text(isCorrect ? "O 정답" : "X 오답") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(isCorrect ? .secondary500 : .primary500) + } else { + Color.clear.frame(height: 14) } - .frame(maxWidth: .infinity) - .padding(12) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige500, lineWidth: 1) - ) } } From 1c5906cdb1e838051a4de3d71279c930a41b2cc8 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 05:56:42 +0900 Subject: [PATCH 19/42] =?UTF-8?q?feat:=20PreVoteView=20=EC=A7=84=EC=9E=85?= =?UTF-8?q?=EC=8B=9C=20=EB=B3=B8=EC=9D=B8=20perspective=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20+=20=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EC=B2=AD=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20/=20Clean=20Architecture=20UseCase=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EB=8F=84=EC=9E=85=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API - GET /api/v1/battles/{battleId}/perspectives/me — 본인 perspective 조회 - DELETE /api/v1/perspectives/{perspectiveId} — perspective 삭제 - BattleAPI/PerspectiveAPI / Service / RepositoryImpl 모두 연결 - PerspectivesQueryRequest 별도 파일로 분리 (Encodable) UseCase 도입 (Attendance_iOS 패턴) - BattleUseCaseImpl / HomeUseCaseImpl / CommentUseCaseImpl / PerspectiveUseCaseImpl 기존 Interface 그대로 채택 + @Dependency 로 Repository 주입 - 각 Impl 에 DependencyKey 채택 (live / test / preview 모두 명시) - DependencyValues 키: battleUseCase / homeUseCase / commentUseCase / perspectiveUseCase - 5개 Feature 의 @Dependency 를 Repository → UseCase 로 전부 교체 PreVoteFeature - voteMode == .pre 일 때만 fetchMyPerspective 호출 - 응답이 있으면 customAlert = .alreadyWatched() 표시 - confirm: deleteMyPerspective(perspectiveId:) 호출 → state.myPerspective = nil - cancel: dismiss - ifLet(\.$customAlert, action: \.scope.customAlert) + CustomConfirmAlert DesignSystem - CustomAlertStyle.alreadyWatched 추가 - CustomAlertState.alreadyWatched() 팩토리 (.pen nah54 매칭) - CustomConfirmationPopupView.alreadyWatchedContent 신규 헤더 / 본문 / 취소·다시 버튼 (.pen 색상 토큰 매칭) PreVoteView - .customAlert($store.scope(state:\.customAlert, action:\.scope.customAlert)) modifier 추가 규칙 / 포맷 - AGENTS.md: 🧩 UseCase 강제 / 📦 Feature 모델 Entity 이동 / 📏 함수 시그니처 멀티라인 규칙 추가 - .swiftformat 에 --disable redundantReturn 추가 — switch case 의 return 자동 제거 방지 --- .swiftformat | 1 + AGENTS.md | 67 +++++++++ .../Data/API/Sources/Battle/BattleAPI.swift | 15 +- .../Sources/Perspective/PerspectiveAPI.swift | 10 +- .../Sources/Battle/BattleRepositoryImpl.swift | 25 +++- .../PerspectiveRepositoryImpl.swift | 11 ++ .../Sources/Battle/BattleService.swift | 54 ++++---- .../Battle/PerspectivesQueryRequest.swift | 27 ++++ .../Perspective/PerspectiveService.swift | 21 +-- .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 4 + .../Perspective/PerspectiveInterface.swift | 3 + .../Home/{ => Battle}/BattleDetail.swift | 0 .../Home/{ => Battle}/BattleVoteStats.swift | 0 .../Home/{ => Battle}/PreVoteBattle.swift | 0 .../Home/{ => Battle}/PreVoteResult.swift | 0 .../Home/{ => Battle}/VoteSummary.swift | 0 .../{ => Comment}/BattlePerspective.swift | 0 .../Sources/Home/{ => Comment}/Comment.swift | 0 .../{ => Comment}/CommentLikeResult.swift | 0 .../{ => Comment}/PerspectiveComment.swift | 0 .../Sources/Home/{ => Core}/BattleTag.swift | 0 .../Sources/Home/{ => Core}/HomeBundle.swift | 0 .../Home/{ => Scenario}/BattleScenario.swift | 0 .../Home/{ => Scenario}/ChatMessage.swift | 0 .../Home/{ => Section}/BestBattle.swift | 0 .../Home/{ => Section}/HeroBattle.swift | 0 .../Home/{ => Section}/HotBattle.swift | 0 .../Home/{ => Section}/NewBattle.swift | 0 .../Home/{ => Section}/QuizQuestion.swift | 0 .../Home/{ => Section}/VoteQuestion.swift | 0 .../Sources/Battle/BattleUseCase.swift | 82 +++++++++++ .../Sources/Comment/CommentUseCase.swift | 38 +++++ .../UseCase/Sources/Home/HomeUseCase.swift | 34 +++++ .../Perspective/PerspectiveUseCase.swift | 73 ++++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 5 +- .../Comment/Reducer/CommentFeature.swift | 17 +-- .../Reducer/CommentReplyFeature.swift | 19 +-- .../Sources/Vote/Reducer/PreVoteFeature.swift | 130 ++++++++++++++++-- .../Chat/Sources/Vote/View/PreVoteView.swift | 1 + .../Sources/Main/Reducer/HomeFeature.swift | 5 +- .../Alert/CustomPopup/CustomAlertState.swift | 12 ++ .../CustomConfirmationPopupView.swift | 60 +++++++- 43 files changed, 621 insertions(+), 94 deletions(-) create mode 100644 Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift rename Projects/Domain/Entity/Sources/Home/{ => Battle}/BattleDetail.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Battle}/BattleVoteStats.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Battle}/PreVoteBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Battle}/PreVoteResult.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Battle}/VoteSummary.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Comment}/BattlePerspective.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Comment}/Comment.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Comment}/CommentLikeResult.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Comment}/PerspectiveComment.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Core}/BattleTag.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Core}/HomeBundle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Scenario}/BattleScenario.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Scenario}/ChatMessage.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/BestBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/HeroBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/HotBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/NewBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/QuizQuestion.swift (100%) rename Projects/Domain/Entity/Sources/Home/{ => Section}/VoteQuestion.swift (100%) create mode 100644 Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift create mode 100644 Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift create mode 100644 Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift create mode 100644 Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift diff --git a/.swiftformat b/.swiftformat index 385d66f3..82430650 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,3 +1,4 @@ --indent 2 --swiftversion 6.0 --maxwidth 120 +--disable redundantReturn diff --git a/AGENTS.md b/AGENTS.md index 5c039f5d..3e9a1909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,6 +499,73 @@ return .run { send in - `.cancellable(id: CancelID.xxx, cancelInFlight: true)` 로 중복 호출 방지 - 레퍼런스: `AuthUseCaseImpl.withDraw` / `HomeFeature.fetchHome` +#### 🧩 UseCase 강제 — Feature 는 Repository 직접 의존 금지 (필수) + +Clean Architecture 의존 방향(`Presentation → Domain ← Data`) 을 지키기 위해 **Feature/Reducer 는 절대로 `@Dependency(\.xxxRepository)` 를 직접 잡지 않는다**. 모든 외부 IO 는 `XxxUseCase` 프로토콜을 통해서만 호출한다. + +```swift +// ✅ 올바른 패턴 — Feature 는 UseCase 만 의존 +@Reducer +public struct PreVoteFeature { + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + + // ... .run { [useCase = battleUseCase] send in + // try await useCase.fetchBattle(battleId: battleId) + // } +} + +// UseCase Impl — Projects/Domain/UseCase/Sources/<도메인>/UseCase.swift +// 별도 protocol 만들지 않고 기존 Interface 를 그대로 채택한다 (Attendance_iOS 패턴). +public struct BattleUseCaseImpl: BattleInterface { + @Dependency(\.battleRepository) private var battleRepository + + public init() {} + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + try await battleRepository.fetchBattle(battleId: battleId) + } + // ... 나머지 메서드도 동일하게 repository 로 단순 위임 +} + +extension BattleUseCaseImpl: DependencyKey { + public static var liveValue = BattleUseCaseImpl() + public static var testValue = BattleUseCaseImpl() + public static var previewValue = BattleUseCaseImpl() +} + +public extension DependencyValues { + var battleUseCase: BattleUseCaseImpl { + get { self[BattleUseCaseImpl.self] } + set { self[BattleUseCaseImpl.self] = newValue } + } +} + +// ❌ 금지 — Feature 가 Repository 를 직접 잡음 +@Reducer +public struct PreVoteFeature { + @Dependency(\.battleRepository) private var battleRepository // ← UseCase 거치게 + @Dependency(\.perspectiveRepository) private var perspectiveRepository +} + +// ❌ 금지 — UseCase 용 새 protocol 을 별도로 만들기 (Interface 중복 정의) +public protocol BattleUseCase: Sendable { ... } +public struct BattleUseCaseImpl: BattleUseCase { ... } +``` + +규칙: +- **Feature/Reducer**: `@Dependency(\.UseCase)` 만 사용 — Repository 키 사용 금지 +- **UseCase Impl**: 기존 `XxxInterface` 를 **그대로 채택** (별도 `XxxUseCase` protocol 만들지 않음 — Attendance_iOS `AuthUseCaseImpl: AuthInterface` 패턴) +- **Repository 잡는 방식**: Impl 안에서 `@Dependency(\.xxxRepository) private var xxxRepository` 로 직접 잡고, `public init() {}` 만 노출 (Attendance 패턴) +- **DependencyKey 채택**: Impl 자체에 `extension XxxUseCaseImpl: DependencyKey { liveValue / testValue / previewValue }` 를 모두 정의 — 별도 `enum XxxUseCaseKey` 만들지 않음 +- **liveValue / testValue / previewValue 모두 명시**: 세 값 모두 `XxxUseCaseImpl()` 로 동일하게 둠 (Attendance 패턴 — 테스트/프리뷰에서 별도 mock 이 필요해지면 그때 교체) +- **DependencyValues 키 이름**: `UseCase` (`battleUseCase`, `homeUseCase`, `commentUseCase`, `perspectiveUseCase`) — 키 타입은 `XxxUseCaseImpl` (Interface 가 아닌 Impl) +- **Repository 키 (`battleRepository`, `homeRepository`, …)** 는 UseCase Impl 안에서만 사용. Presentation 에서는 호출 금지 +- 동일 도메인의 모든 IO 를 하나의 UseCase 파일에 모음 (multi-method UseCase). 액션 1개짜리 도메인이면 Attendance `FetchMyAttendancesUseCase` 처럼 별도 protocol 1개 + Impl 1개 형태도 OK +- 새 IO 가 추가되면: 1) Repository 메서드 추가 → 2) UseCase Impl 에 위임 메서드 추가 → 3) Feature 에서 UseCase 호출 + +레퍼런스: `BattleUseCaseImpl`, `HomeUseCaseImpl`, `CommentUseCaseImpl`, `PerspectiveUseCaseImpl`, Attendance_iOS `AuthUseCaseImpl` + #### 🔌 RepositoryImpl — Provider 선언 패턴 Repository 구현체의 `MoyaProvider` 는 `let` 으로 직접 선언하고, init 기본값으로 `.default` / `.authorized` 팩토리를 그대로 사용한다. `Optional + nil 합치기`나 `MoyaProviderPool` 인다이렉션 금지. diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index dbfdfc24..522a3859 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -12,21 +12,24 @@ public enum BattleAPI { case scenario(battleId: Int) case voteStats(battleId: Int) case perspectives(battleId: Int) + case myPerspective(battleId: Int) public var description: String { switch self { case let .detail(battleId): - "\(battleId)" + return "\(battleId)" case let .preVote(battleId): - "\(battleId)/votes/pre" + return "\(battleId)/votes/pre" case let .postVote(battleId): - "\(battleId)/votes/post" + return "\(battleId)/votes/post" case let .scenario(battleId): - "\(battleId)/scenario" + return "\(battleId)/scenario" case let .voteStats(battleId): - "\(battleId)/vote-stats" + return "\(battleId)/vote-stats" case let .perspectives(battleId): - "\(battleId)/perspectives" + return "\(battleId)/perspectives" + case let .myPerspective(battleId): + return "\(battleId)/perspectives/me" } } } diff --git a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift index 554aea04..acf4e1e8 100644 --- a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift +++ b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift @@ -15,15 +15,15 @@ public enum PerspectiveAPI { public var description: String { switch self { case let .detail(perspectiveId): - "\(perspectiveId)" + return "\(perspectiveId)" case let .listLabeledComments(perspectiveId): - "\(perspectiveId)/comments/labeled" + return "\(perspectiveId)/comments/labeled" case let .createComment(perspectiveId): - "\(perspectiveId)/comments" + return "\(perspectiveId)/comments" case let .updateComment(perspectiveId, commentId): - "\(perspectiveId)/comments/\(commentId)" + return "\(perspectiveId)/comments/\(commentId)" case let .deleteComment(perspectiveId, commentId): - "\(perspectiveId)/comments/\(commentId)" + return "\(perspectiveId)/comments/\(commentId)" } } } diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index ebab8c88..9e756760 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -90,10 +90,12 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { let dto: BattlePerspectivePageResponseDTO = try await provider.request( .perspectives( battleId: battleId, - cursor: cursor, - size: size, - optionLabel: optionLabel, - sort: sort?.queryValue + query: PerspectivesQueryRequest( + cursor: cursor, + size: size, + optionLabel: optionLabel, + sort: sort?.queryValue + ) ) ) @@ -127,6 +129,21 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { + let dto: BaseResponseDTO + do { + dto = try await provider.request(.myPerspective(battleId: battleId)) + } catch { + Log.debug("[BattleRepositoryImpl] fetchMyPerspective failed (no participation): \(error.localizedDescription)") + return nil + } + + if dto.statusCode >= 400 { + return nil + } + return dto.data?.toDomain() + } + public func fetchScenario(battleId: Int) async throws -> BattleScenario { let dto: BattleScenarioResponseDTO = try await provider.request( .scenario(battleId: battleId) diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift index e52195b9..b914aaeb 100644 --- a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -105,6 +105,17 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S throw CommentError.backendError(message) } } + + public func deletePerspective(perspectiveId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .deletePerspective(perspectiveId: perspectiveId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "perspective 삭제 실패" + throw BattleError.backendError(message) + } + } } public struct EmptyDTO: Decodable {} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 26acb45e..7e6acfc5 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -13,6 +13,7 @@ import AsyncMoya public struct CreatePerspectiveRequest: Encodable { public let content: String public let optionId: Int + public init(content: String, optionId: Int) { self.content = content self.optionId = optionId @@ -25,8 +26,9 @@ public enum BattleService { case postVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) case voteStats(battleId: Int) - case perspectives(battleId: Int, cursor: String?, size: Int?, optionLabel: String?, sort: String?) + case perspectives(battleId: Int, query: PerspectivesQueryRequest) case createPerspective(battleId: Int, body: CreatePerspectiveRequest) + case myPerspective(battleId: Int) } extension BattleService: BaseTargetType { @@ -37,19 +39,21 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { case let .detail(battleId): - BattleAPI.detail(battleId: battleId).description + return BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): - BattleAPI.preVote(battleId: battleId).description + return BattleAPI.preVote(battleId: battleId).description case let .postVote(battleId, _): - BattleAPI.postVote(battleId: battleId).description + return BattleAPI.postVote(battleId: battleId).description case let .scenario(battleId): - BattleAPI.scenario(battleId: battleId).description + return BattleAPI.scenario(battleId: battleId).description case let .voteStats(battleId): - BattleAPI.voteStats(battleId: battleId).description - case let .perspectives(battleId, _, _, _, _): - BattleAPI.perspectives(battleId: battleId).description + return BattleAPI.voteStats(battleId: battleId).description + case let .perspectives(battleId, _): + return BattleAPI.perspectives(battleId: battleId).description case let .createPerspective(battleId, _): - BattleAPI.perspectives(battleId: battleId).description + return BattleAPI.perspectives(battleId: battleId).description + case let .myPerspective(battleId): + return BattleAPI.myPerspective(battleId: battleId).description } } @@ -57,20 +61,10 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail: - .get - case .preVote: - .post - case .postVote: - .post - case .scenario: - .get - case .voteStats: - .get - case .perspectives: - .get - case .createPerspective: - .post + case .detail, .scenario, .voteStats, .perspectives, .myPerspective: + return .get + case .preVote, .postVote, .createPerspective: + return .post } } @@ -86,19 +80,17 @@ extension BattleService: BaseTargetType { return nil case .voteStats: return nil - case let .perspectives(_, cursor, size, optionLabel, sort): - var query: [String: Any] = [:] - if let cursor { query["cursor"] = cursor } - if let size { query["size"] = size } - if let optionLabel { query["optionLabel"] = optionLabel } - if let sort { query["sort"] = sort } - return query.isEmpty ? nil : query + case let .perspectives(_, query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict case let .createPerspective(_, body): return body.toDictionary + case .myPerspective: + return nil } } public var headers: [String: String]? { - APIHeader.baseHeader + return APIHeader.baseHeader } } diff --git a/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift new file mode 100644 index 00000000..9e92fbb7 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift @@ -0,0 +1,27 @@ +// +// PerspectivesQueryRequest.swift +// Service +// +// GET /api/v1/battles/{battleId}/perspectives 쿼리 파라미터. +// + +import Foundation + +public struct PerspectivesQueryRequest: Encodable { + public let cursor: String? + public let size: Int? + public let optionLabel: String? + public let sort: String? + + public init( + cursor: String? = nil, + size: Int? = nil, + optionLabel: String? = nil, + sort: String? = nil + ) { + self.cursor = cursor + self.size = size + self.optionLabel = optionLabel + self.sort = sort + } +} diff --git a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift index 2582228d..92a92553 100644 --- a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift +++ b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift @@ -21,6 +21,7 @@ public enum PerspectiveService { case createComment(perspectiveId: Int, body: PerspectiveCommentBody) case updateComment(perspectiveId: Int, commentId: Int, body: PerspectiveCommentBody) case deleteComment(perspectiveId: Int, commentId: Int) + case deletePerspective(perspectiveId: Int) } extension PerspectiveService: BaseTargetType { @@ -31,15 +32,17 @@ extension PerspectiveService: BaseTargetType { public var urlPath: String { switch self { case let .detail(perspectiveId): - PerspectiveAPI.detail(perspectiveId: perspectiveId).description + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description case let .listLabeledComments(perspectiveId, _, _): - PerspectiveAPI.listLabeledComments(perspectiveId: perspectiveId).description + return PerspectiveAPI.listLabeledComments(perspectiveId: perspectiveId).description case let .createComment(perspectiveId, _): - PerspectiveAPI.createComment(perspectiveId: perspectiveId).description + return PerspectiveAPI.createComment(perspectiveId: perspectiveId).description case let .updateComment(perspectiveId, commentId, _): - PerspectiveAPI.updateComment(perspectiveId: perspectiveId, commentId: commentId).description + return PerspectiveAPI.updateComment(perspectiveId: perspectiveId, commentId: commentId).description case let .deleteComment(perspectiveId, commentId): - PerspectiveAPI.deleteComment(perspectiveId: perspectiveId, commentId: commentId).description + return PerspectiveAPI.deleteComment(perspectiveId: perspectiveId, commentId: commentId).description + case let .deletePerspective(perspectiveId): + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description } } @@ -47,15 +50,13 @@ extension PerspectiveService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail: - .get - case .listLabeledComments: + case .detail, .listLabeledComments: .get case .createComment: .post case .updateComment: .put - case .deleteComment: + case .deleteComment, .deletePerspective: .delete } } @@ -75,6 +76,8 @@ extension PerspectiveService: BaseTargetType { return body.toDictionary case .deleteComment: return nil + case .deletePerspective: + return nil } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 9d2eaa63..dd517a4b 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -25,6 +25,7 @@ public protocol BattleInterface: Sendable { content: String, optionId: Int ) async throws -> BattlePerspective + func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index a7f1445a..e882e2fb 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -84,4 +84,8 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { createdAt: Date() ) } + + public func fetchMyPerspective(battleId _: Int) async throws -> BattlePerspective? { + nil + } } diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift index 90fb1e54..77be2237 100644 --- a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -25,6 +25,7 @@ public protocol PerspectiveInterface: Sendable { content: String ) async throws -> PerspectiveCommentMutationResult func deleteComment(perspectiveId: Int, commentId: Int) async throws + func deletePerspective(perspectiveId: Int) async throws } public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { @@ -68,6 +69,8 @@ public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { } public func deleteComment(perspectiveId _: Int, commentId _: Int) async throws {} + + public func deletePerspective(perspectiveId _: Int) async throws {} } public struct PerspectiveRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/Entity/Sources/Home/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BattleDetail.swift rename to Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift diff --git a/Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BattleVoteStats.swift rename to Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/PreVoteResult.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift diff --git a/Projects/Domain/Entity/Sources/Home/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/VoteSummary.swift rename to Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift diff --git a/Projects/Domain/Entity/Sources/Home/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BattlePerspective.swift rename to Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift diff --git a/Projects/Domain/Entity/Sources/Home/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Comment.swift rename to Projects/Domain/Entity/Sources/Home/Comment/Comment.swift diff --git a/Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/CommentLikeResult.swift rename to Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift diff --git a/Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/PerspectiveComment.swift rename to Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift diff --git a/Projects/Domain/Entity/Sources/Home/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BattleTag.swift rename to Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/Core/HomeBundle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HomeBundle.swift rename to Projects/Domain/Entity/Sources/Home/Core/HomeBundle.swift diff --git a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BattleScenario.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/ChatMessage.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift diff --git a/Projects/Domain/Entity/Sources/Home/BestBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BestBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/HeroBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HeroBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HotBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/NewBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/QuizQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/QuizQuestion.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/QuizQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/QuizQuestion.swift diff --git a/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/VoteQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift new file mode 100644 index 00000000..8404ac75 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -0,0 +1,82 @@ +// +// BattleUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct BattleUseCaseImpl: BattleInterface { + @Dependency(\.battleRepository) private var battleRepository + + public init() {} + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + return try await battleRepository.fetchBattle(battleId: battleId) + } + + public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + return try await battleRepository.submitPreVote(battleId: battleId, optionId: optionId) + } + + public func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + return try await battleRepository.submitPostVote(battleId: battleId, optionId: optionId) + } + + public func fetchScenario(battleId: Int) async throws -> BattleScenario { + return try await battleRepository.fetchScenario(battleId: battleId) + } + + public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { + return try await battleRepository.fetchVoteStats(battleId: battleId) + } + + public func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionLabel: String?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + return try await battleRepository.fetchPerspectives( + battleId: battleId, + cursor: cursor, + size: size, + optionLabel: optionLabel, + sort: sort + ) + } + + public func createPerspective( + battleId: Int, + content: String, + optionId: Int + ) async throws -> BattlePerspective { + return try await battleRepository.createPerspective( + battleId: battleId, + content: content, + optionId: optionId + ) + } + + public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { + return try await battleRepository.fetchMyPerspective(battleId: battleId) + } +} + +extension BattleUseCaseImpl: DependencyKey { + public static var liveValue = BattleUseCaseImpl() + public static var testValue = BattleUseCaseImpl() + public static var previewValue = BattleUseCaseImpl() +} + +public extension DependencyValues { + var battleUseCase: BattleUseCaseImpl { + get { self[BattleUseCaseImpl.self] } + set { self[BattleUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift b/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift new file mode 100644 index 00000000..859f9f9d --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift @@ -0,0 +1,38 @@ +// +// CommentUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct CommentUseCaseImpl: CommentInterface { + @Dependency(\.commentRepository) private var commentRepository + + public init() {} + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + return try await commentRepository.likeComment(commentId: commentId) + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + return try await commentRepository.unlikeComment(commentId: commentId) + } +} + +extension CommentUseCaseImpl: DependencyKey { + public static var liveValue = CommentUseCaseImpl() + public static var testValue = CommentUseCaseImpl() + public static var previewValue = CommentUseCaseImpl() +} + +public extension DependencyValues { + var commentUseCase: CommentUseCaseImpl { + get { self[CommentUseCaseImpl.self] } + set { self[CommentUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift b/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift new file mode 100644 index 00000000..bd23ed7e --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift @@ -0,0 +1,34 @@ +// +// HomeUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct HomeUseCaseImpl: HomeInterface { + @Dependency(\.homeRepository) private var homeRepository + + public init() {} + + public func fetchHome() async throws -> HomeBundle { + return try await homeRepository.fetchHome() + } +} + +extension HomeUseCaseImpl: DependencyKey { + public static var liveValue = HomeUseCaseImpl() + public static var testValue = HomeUseCaseImpl() + public static var previewValue = HomeUseCaseImpl() +} + +public extension DependencyValues { + var homeUseCase: HomeUseCaseImpl { + get { self[HomeUseCaseImpl.self] } + set { self[HomeUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift new file mode 100644 index 00000000..aebcb57b --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift @@ -0,0 +1,73 @@ +// +// PerspectiveUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct PerspectiveUseCaseImpl: PerspectiveInterface { + @Dependency(\.perspectiveRepository) private var perspectiveRepository + + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + return try await perspectiveRepository.fetchPerspective(perspectiveId: perspectiveId) + } + + public func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + return try await perspectiveRepository.fetchLabeledComments( + perspectiveId: perspectiveId, + cursor: cursor, + size: size + ) + } + + public func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + return try await perspectiveRepository.createComment(perspectiveId: perspectiveId, content: content) + } + + public func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + return try await perspectiveRepository.updateComment( + perspectiveId: perspectiveId, + commentId: commentId, + content: content + ) + } + + public func deleteComment(perspectiveId: Int, commentId: Int) async throws { + return try await perspectiveRepository.deleteComment(perspectiveId: perspectiveId, commentId: commentId) + } + + public func deletePerspective(perspectiveId: Int) async throws { + return try await perspectiveRepository.deletePerspective(perspectiveId: perspectiveId) + } +} + +extension PerspectiveUseCaseImpl: DependencyKey { + public static var liveValue = PerspectiveUseCaseImpl() + public static var testValue = PerspectiveUseCaseImpl() + public static var previewValue = PerspectiveUseCaseImpl() +} + +public extension DependencyValues { + var perspectiveUseCase: PerspectiveUseCaseImpl { + get { self[PerspectiveUseCaseImpl.self] } + set { self[PerspectiveUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index dc84a4d7..80698c93 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -10,6 +10,7 @@ import Foundation import ComposableArchitecture import DesignSystem import DomainInterface +import UseCase import Entity import LogMacro @@ -232,7 +233,7 @@ public struct ChatRoomFeature { case audioObserver } - @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.audioPlayer) private var audioPlayer public var body: some Reducer { @@ -357,7 +358,7 @@ extension ChatRoomFeature { case .fetchScenario: state.isLoadingScenario = true let battleId = state.battleId - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchScenario(battleId: battleId) } diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 7e7032cb..7d8f7673 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -11,6 +11,7 @@ import Foundation import ComposableArchitecture import DesignSystem import DomainInterface +import UseCase import Entity import LogMacro @@ -117,9 +118,9 @@ public struct CommentFeature { case createComment } - @Dependency(\.battleRepository) private var battleRepository - @Dependency(\.commentRepository) private var commentRepository - @Dependency(\.perspectiveRepository) private var perspectiveRepository + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.commentUseCase) private var commentUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase public var body: some Reducer { BindingReducer() @@ -264,7 +265,7 @@ extension CommentFeature { switch action { case .fetchBattle: let battleId = state.battleId - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchBattle(battleId: battleId) } @@ -276,7 +277,7 @@ extension CommentFeature { case .fetchVoteStats: state.isLoadingStats = true let battleId = state.battleId - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchVoteStats(battleId: battleId) } @@ -291,7 +292,7 @@ extension CommentFeature { let cursor = reset ? nil : state.nextCursor let optionLabel = state.selectedFilter.queryLabel let sort = state.selectedSort.perspectiveSort - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchPerspectives( battleId: battleId, @@ -307,7 +308,7 @@ extension CommentFeature { .cancellable(id: CancelID.fetchPerspectives, cancelInFlight: true) case let .toggleLike(commentId, currentlyLiked): - return .run { [repository = commentRepository] send in + return .run { [repository = commentUseCase] send in let result = await Result { if currentlyLiked { try await repository.unlikeComment(commentId: commentId) @@ -321,7 +322,7 @@ extension CommentFeature { .cancellable(id: CancelID.toggleLike, cancelInFlight: false) case let .createComment(perspectiveId, content): - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.createComment(perspectiveId: perspectiveId, content: content) } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index a907b282..2646c0f9 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -15,6 +15,7 @@ import Foundation import ComposableArchitecture import DomainInterface +import UseCase import Entity import LogMacro @@ -98,8 +99,8 @@ public struct CommentReplyFeature { case like } - @Dependency(\.perspectiveRepository) private var perspectiveRepository - @Dependency(\.commentRepository) private var commentRepository + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.commentUseCase) private var commentUseCase public var body: some Reducer { BindingReducer() @@ -199,7 +200,7 @@ extension CommentReplyFeature { case .fetchParent: state.isLoadingDetail = true let pid = state.perspectiveId - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.fetchPerspective(perspectiveId: pid) } @@ -212,7 +213,7 @@ extension CommentReplyFeature { state.isLoadingReplies = true let pid = state.perspectiveId let cursor = reset ? nil : state.nextCursor - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 20) } @@ -223,7 +224,7 @@ extension CommentReplyFeature { case let .createReply(content): let pid = state.perspectiveId - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.createComment(perspectiveId: pid, content: content) } @@ -234,7 +235,7 @@ extension CommentReplyFeature { case let .updateReply(commentId, content): let pid = state.perspectiveId - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.updateComment(perspectiveId: pid, commentId: commentId, content: content) } @@ -245,7 +246,7 @@ extension CommentReplyFeature { case let .deleteReply(commentId): let pid = state.perspectiveId - return .run { [repository = perspectiveRepository] send in + return .run { [repository = perspectiveUseCase] send in let result = await Result { try await repository.deleteComment(perspectiveId: pid, commentId: commentId) return commentId @@ -257,7 +258,7 @@ extension CommentReplyFeature { case let .toggleParentLike(currentlyLiked): let commentId = state.perspectiveId - return .run { [repository = commentRepository] send in + return .run { [repository = commentUseCase] send in let result = await Result { if currentlyLiked { try await repository.unlikeComment(commentId: commentId) @@ -271,7 +272,7 @@ extension CommentReplyFeature { .cancellable(id: CancelID.like, cancelInFlight: false) case let .toggleReplyLike(commentId, currentlyLiked): - return .run { [repository = commentRepository] send in + return .run { [repository = commentUseCase] send in let result = await Result { if currentlyLiked { try await repository.unlikeComment(commentId: commentId) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 9cbecc57..c115efff 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -9,7 +9,9 @@ import Foundation import UIKit import ComposableArchitecture +import DesignSystem import DomainInterface +import UseCase import Entity import LogMacro @@ -32,6 +34,8 @@ public struct PreVoteFeature { public var shareItem: ShareItem? public var battleId: Int = 0 public var voteMode: VoteMode = .pre + public var myPerspective: BattlePerspective? + @Presents public var customAlert: CustomAlertState? public var isPrimaryButtonEnabled: Bool { selectedOptionId != nil && !isSubmitting @@ -73,6 +77,7 @@ public struct PreVoteFeature { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } @@ -87,6 +92,8 @@ public struct PreVoteFeature { public enum AsyncAction: Equatable { case fetchBattleDetail + case fetchMyPerspective + case deleteMyPerspective(perspectiveId: Int) case prepareShare(title: String, url: String, imageURL: String?) case submitPreVote(battleId: Int, optionId: Int) case submitPostVote(battleId: Int, optionId: Int) @@ -94,11 +101,22 @@ public struct PreVoteFeature { public enum InnerAction: Equatable { case battleDetailResponse(Result) + case myPerspectiveResponse(Result) + case deleteMyPerspectiveResponse(Result) case preVoteResponse(Result) case sharePrepared(ShareItem) case postVoteResponse(Result) } + public struct EmptyResult: Equatable { + public init() {} + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + public enum DelegateAction: Equatable { case dismiss case voteSubmitted(battleId: Int, voteMode: VoteMode, result: PreVoteResult) @@ -106,32 +124,41 @@ public struct PreVoteFeature { nonisolated enum CancelID: Hashable { case fetchBattleDetail + case fetchMyPerspective + case deleteMyPerspective case submitPreVote case submitPostVote } - @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .binding: - .none + return .none case let .view(viewAction): - handleViewAction(state: &state, action: viewAction) + return handleViewAction(state: &state, action: viewAction) case let .async(asyncAction): - handleAsyncAction(state: &state, action: asyncAction) + return handleAsyncAction(state: &state, action: asyncAction) case let .inner(innerAction): - handleInnerAction(state: &state, action: innerAction) + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) case let .delegate(delegateAction): - handleDelegateAction(state: &state, action: delegateAction) + return handleDelegateAction(state: &state, action: delegateAction) } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -142,11 +169,14 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: - guard state.battleDetail == nil, - state.battle == nil, - !state.isLoading - else { return .none } - return .send(.async(.fetchBattleDetail)) + var effects: [Effect] = [] + if state.battleDetail == nil, state.battle == nil, !state.isLoading { + effects.append(.send(.async(.fetchBattleDetail))) + } + if state.voteMode == .pre, state.myPerspective == nil { + effects.append(.send(.async(.fetchMyPerspective))) + } + return effects.isEmpty ? .none : .merge(effects) case .backButtonTapped: return .send(.delegate(.dismiss)) @@ -182,7 +212,7 @@ extension PreVoteFeature { case .fetchBattleDetail: state.isLoading = true let battleId = state.battleId - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchBattle(battleId: battleId) } @@ -191,6 +221,28 @@ extension PreVoteFeature { } .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) + case .fetchMyPerspective: + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchMyPerspective(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.myPerspectiveResponse(result))) + } + .cancellable(id: CancelID.fetchMyPerspective, cancelInFlight: true) + + case let .deleteMyPerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.deletePerspective(perspectiveId: perspectiveId) + return EmptyResult() + } + .mapError(BattleError.from) + return await send(.inner(.deleteMyPerspectiveResponse(result))) + } + .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) + case let .prepareShare(title, url, imageURL): return .run { send in var items: [Any] = [title, url] @@ -207,7 +259,7 @@ extension PreVoteFeature { } case let .submitPreVote(battleId, optionId): - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.submitPreVote(battleId: battleId, optionId: optionId) } @@ -217,7 +269,7 @@ extension PreVoteFeature { .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) case let .submitPostVote(battleId, optionId): - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.submitPostVote(battleId: battleId, optionId: optionId) } @@ -244,6 +296,27 @@ extension PreVoteFeature { } return .none + case let .myPerspectiveResponse(result): + switch result { + case let .success(perspective): + state.myPerspective = perspective + if perspective != nil { + state.customAlert = .alreadyWatched() + } + case let .failure(error): + Log.error("[PreVoteFeature] fetchMyPerspective failed: \(error.localizedDescription)") + } + return .none + + case let .deleteMyPerspectiveResponse(result): + switch result { + case .success: + state.myPerspective = nil + case let .failure(error): + Log.error("[PreVoteFeature] deleteMyPerspective failed: \(error.localizedDescription)") + } + return .none + case let .preVoteResponse(result): state.isSubmitting = false switch result { @@ -307,7 +380,34 @@ extension PreVoteFeature { ) -> Effect { switch action { case .dismiss, .voteSubmitted: - .none + return .none + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + let perspectiveId = state.myPerspective?.perspectiveId + state.customAlert = nil + guard let perspectiveId else { return .none } + return .send(.async(.deleteMyPerspective(perspectiveId: perspectiveId))) + + case .cancelTapped: + state.customAlert = nil + return .send(.delegate(.dismiss)) + } + + case .dismiss: + state.customAlert = nil + return .none + } } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 8d3c8301..de0e14cf 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -55,6 +55,7 @@ public struct PreVoteView: View { .presentationDetents([.fraction(0.6)]) .toolbar(.hidden, for: .navigationBar) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } private var shouldShowSkeleton: Bool { diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 5ea33f4e..00cbe01e 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,6 +7,7 @@ import ComposableArchitecture import DomainInterface +import UseCase import Entity import Foundation import LogMacro @@ -77,7 +78,7 @@ public struct HomeFeature { case fetchHome } - @Dependency(\.homeRepository) private var homeRepository + @Dependency(\.homeUseCase) private var homeUseCase public var body: some Reducer { BindingReducer() @@ -139,7 +140,7 @@ extension HomeFeature { switch action { case .fetchHome: state.isLoading = true - return .run { [repository = homeRepository] send in + return .run { [repository = homeUseCase] send in let result = await Result { try await repository.fetchHome() } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 4476b900..2a8a761a 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -36,6 +36,7 @@ public enum CustomAlertStyle: Equatable { case confirmation case finalVote case report + case alreadyWatched } @CasePathable @@ -89,4 +90,15 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .report ) } + + static func alreadyWatched() -> CustomAlertState { + CustomAlertState( + title: "다시 콘텐츠를 시청하시겠습니까?", + message: "이미 참여 완료한 배틀입니다.\n기존 내역이 삭제되고\n다시 처음부터 진행됩니다.", + confirmTitle: "다시", + cancelTitle: "취소", + isDestructive: true, + style: .alreadyWatched + ) + } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 4c2140a5..f6b53faa 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -59,9 +59,9 @@ struct CustomConfirmationPopup: View { private var popupHorizontalPadding: CGFloat { switch style { case .confirmation: - 20 - case .finalVote, .report: - 0 + return 20 + case .finalVote, .report, .alreadyWatched: + return 0 } } @@ -74,9 +74,63 @@ struct CustomConfirmationPopup: View { finalVoteContent case .report: reportContent + case .alreadyWatched: + alreadyWatchedContent } } + @ViewBuilder + private var alreadyWatchedContent: some View { + VStack(spacing: 12) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .kerning(-0.4) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + if !message.isEmpty { + Text(message) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.neutral400) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + + HStack(spacing: 0) { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "취소" : cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "다시" : confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 24) + .frame(width: 343) + .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.primary500, lineWidth: 1.5) + ) + .onTapGesture {} + } + private var confirmationContent: some View { VStack(spacing: 24) { VStack(spacing: 8) { From c34214474b90b9ea8fd01d71fa10602318c3e35a Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 05:58:46 +0900 Subject: [PATCH 20/42] =?UTF-8?q?docs:=20AGENTS.md=20=E2=80=94=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EB=8B=A8=EC=9C=84=20/=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80=20(30=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EB=B6=84=ED=95=A0?= =?UTF-8?q?)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3e9a1909..2f645270 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -775,6 +775,22 @@ public var method: Moya.Method { - 커밋 메시지에 `Co-Authored-By: Claude ...` 등의 자동 서명 라인을 절대 추가하지 않음 - OMX 훅 검증을 위해 커밋 명령에는 `Co-authored-by: OmX ` trailer를 포함하되, 최종 커밋 메시지에서는 제거 +### 📦 커밋 단위 / 크기 규칙 — 30 파일 이상이면 끊어서 (필수) + +한 번에 너무 많은 파일을 묶으면 리뷰가 불가능하고 회귀 발생 시 bisect 가 어렵다. 변경된 파일 수가 많으면 **의미 단위로 끊어서 여러 커밋**으로 나눈다. + +규칙: +- 한 커밋의 **변경 파일 수가 30개를 초과하면 반드시 분할** +- 분할 기준은 **모듈 / 도메인 / 변경 종류** 가 우선: + - 예) `API + Service + RepositoryImpl` 같이 도메인 레이어 한 줄기 → 1 커밋 + - 예) `UseCase 신규 정의` → 1 커밋 + - 예) `Feature 들의 @Dependency 교체` → 1 커밋 + - 예) `View / Reducer UI 변경` → 1 커밋 + - 예) `AGENTS.md / .swiftformat 같은 규칙 / 설정` → 1 커밋 +- 분할 후 각 커밋은 **단독으로 빌드 가능**해야 함 (의존하는 다른 커밋이 같은 PR 안에 있으면 OK, 다른 PR 에 있으면 분할 순서 조정) +- 작업 시작 전에 변경 범위를 보고 **30 파일이 넘을 것 같으면 미리 단계로 쪼개기** +- 단순 포맷터 / 자동 변환 (예: 전역 시그니처 멀티라인) 은 30 파일 넘어도 1 커밋 OK — 단 커밋 메시지에 "전역 자동 변환" 명시 + ### 🧭 TCAFlow 네비게이션 (`docs/agent/tcaflow-navigation.md`) - @FlowCoordinator 패턴 - 기본 네비게이션 동작 (Push, Present, Dismiss) From 0a4f12476158b3b6871726531461f339d0531b72 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 23 May 2026 06:21:43 +0900 Subject: [PATCH 21/42] =?UTF-8?q?refactor:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=E2=80=94=20multi-param=20=ED=95=A8=EC=88=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B7=B8=EB=8B=88=EC=B2=98=20=EB=A9=80=ED=8B=B0=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=95=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md 의 `📏 함수 시그니처` 규칙(파라미터 ≥ 2 → 멀티 라인) 적용. 자동 일괄 변환이라 30+ 파일 단일 커밋 (커밋 단위 규칙 예외). --- Projects/App/Sources/Reducer/AppReducer.swift | 5 +++- .../Sources/Home/Mapper/HomeDataDTO+.swift | 5 +++- .../Sources/Battle/BattleRepositoryImpl.swift | 10 ++++++-- .../Google/GoogleOAuthConfiguration.swift | 5 +++- .../Apple/AppleOAuthRepositoryImpl.swift | 10 ++++++-- .../OAuth/Web/OAuthWebViewController.swift | 20 ++++++++++++--- .../PerspectiveRepositoryImpl.swift | 5 +++- .../Sources/Battle/BattleService.swift | 5 +++- .../Sources/Apple/AppleOAuthInterface.swift | 5 +++- .../Apple/MockAppleOAuthRepository.swift | 5 +++- .../Sources/Battle/BattleInterface.swift | 10 ++++++-- .../Battle/DefaultBattleRepositoryImpl.swift | 10 ++++++-- .../DefaultMemoryKeychainManager.swift | 5 +++- .../Manager/KeychainManagerInterface.swift | 5 +++- .../Sources/Manager/MockKeychainManager.swift | 5 +++- .../Perspective/PerspectiveInterface.swift | 10 ++++++-- .../Sources/Home/Battle/PreVoteResult.swift | 5 +++- .../Home/Comment/BattlePerspective.swift | 13 ++++++++-- .../Entity/Sources/Home/Comment/Comment.swift | 17 ++++++++++--- .../Home/Comment/CommentLikeResult.swift | 6 ++++- .../Home/Comment/PerspectiveComment.swift | 12 +++++++-- .../Entity/Sources/Home/Core/BattleTag.swift | 6 ++++- .../Home/Scenario/BattleScenario.swift | 12 +++++++-- .../Sources/Home/Section/VoteQuestion.swift | 5 +++- .../Sources/Battle/BattleUseCase.swift | 10 ++++++-- .../Sources/Manager/KeychainManager.swift | 10 ++++++-- .../Perspective/PerspectiveUseCase.swift | 5 +++- .../Coordinator/Reducer/AuthCoordinator.swift | 5 +++- .../Reducer/OnBoardingFeature.swift | 7 +++++- .../Components/OnBoardingPageIndicator.swift | 5 +++- .../ChatRoom/Reducer/ChatRoomFeature.swift | 25 +++++++++++++++---- .../Sources/ChatRoom/View/ChatRoomView.swift | 10 ++++++-- .../Comment/Reducer/CommentFeature.swift | 5 +++- .../Sources/Comment/View/CommentView.swift | 10 ++++++-- .../CommentReply/View/CommentReplyView.swift | 5 +++- .../Coordinator/Reducer/ChatCoordinator.swift | 5 +++- .../Sources/Vote/Reducer/PreVoteFeature.swift | 5 +++- .../Coordinator/Reducer/HomeCoordinator.swift | 5 +++- .../Main/View/Components/VoteCardView.swift | 10 ++++++-- .../SplashLogoAnimatedImageView.swift | 5 +++- .../Sources/CustomFont/PretendardFont.swift | 15 ++++++++--- .../Sources/Extension/Color/Color+.swift | 5 +++- .../Sources/Extension/Color/UIColor+.swift | 5 +++- .../CustomConfirmationPopupView.swift | 5 +++- .../Sources/UI/Button/CTAButtonStyle.swift | 5 +++- .../Sources/UI/Share/ShareSheet.swift | 5 +++- 46 files changed, 293 insertions(+), 70 deletions(-) diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 1e1cd6e7..a8fc4724 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -207,7 +207,10 @@ public struct AppReducer: Sendable { } // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 - private func isValidAction(_ action: ScopeAction, for state: State) -> Bool { + private func isValidAction( + _ action: ScopeAction, + for state: State + ) -> Bool { switch (action, state) { case (.auth, .auth), (.splash, .splash), (.mainTab, .mainTab): return true diff --git a/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift index 44fd2831..3fffc24a 100644 --- a/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift +++ b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift @@ -15,7 +15,10 @@ public extension TagDTO { } public extension EditorPickDTO { - func toDomain(position: Int, total: Int) -> HeroBattle { + func toDomain( + position: Int, + total: Int + ) -> HeroBattle { HeroBattle( battleId: battleId, badge: "EDITOR PICK", diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 9e756760..e98ede4a 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -38,7 +38,10 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } - public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + public func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { let dto: PreVoteResponseDTO = try await provider.request( .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) ) @@ -66,7 +69,10 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } - public func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + public func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { let dto: PreVoteResponseDTO = try await provider.request( .postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) ) diff --git a/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift index 6feac255..8b215ed1 100644 --- a/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift +++ b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift @@ -22,7 +22,10 @@ public struct GoogleOAuthConfiguration { !serverClientID.contains("GOOGLE_CLIENT_ID") } - public init(clientID: String, serverClientID: String) { + public init( + clientID: String, + serverClientID: String + ) { self.clientID = clientID self.serverClientID = serverClientID } diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift index a5aeaf3a..1b452346 100644 --- a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift @@ -31,7 +31,10 @@ public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unc public override init() { } - public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + public func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { // 받은 credential으로 직접 payload 생성 guard let identityTokenData = credential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) @@ -92,7 +95,10 @@ public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unc // MARK: - ASAuthorizationControllerDelegate extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate { - public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { finishSignIn(with: .failure(AuthError.invalidCredential("Invalid credential type"))) return diff --git a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift index 4e2b38c9..a26de3ea 100644 --- a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift +++ b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift @@ -243,7 +243,10 @@ final class OAuthWebViewController: UIViewController { } } - private func finish(_ result: Result, animated: Bool = true) { + private func finish( + _ result: Result, + animated: Bool = true + ) { guard !didFinish else { return } didFinish = true let completion = onComplete @@ -292,19 +295,28 @@ private final class OAuthSheetDragHandleView: UIControl { fatalError("init(coder:) has not been implemented") } - override func beginTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + override func beginTracking( + _ touch: UITouch, + with _: UIEvent? + ) -> Bool { initialTouchY = touch.location(in: nil).y return true } - override func continueTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + override func continueTracking( + _ touch: UITouch, + with _: UIEvent? + ) -> Bool { guard let initialTouchY else { return false } let currentY = touch.location(in: nil).y events.send(.changed(currentY - initialTouchY)) return true } - override func endTracking(_ touch: UITouch?, with _: UIEvent?) { + override func endTracking( + _ touch: UITouch?, + with _: UIEvent? + ) { defer { initialTouchY = nil } guard let touch, let initialTouchY diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift index b914aaeb..b5933191 100644 --- a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -95,7 +95,10 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S return data.toDomain() } - public func deleteComment(perspectiveId: Int, commentId: Int) async throws { + public func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { let dto: BaseResponseDTO = try await provider.request( .deleteComment(perspectiveId: perspectiveId, commentId: commentId) ) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 7e6acfc5..96057566 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -14,7 +14,10 @@ public struct CreatePerspectiveRequest: Encodable { public let content: String public let optionId: Int - public init(content: String, optionId: Int) { + public init( + content: String, + optionId: Int + ) { self.content = content self.optionId = optionId } diff --git a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift index 8e2a7b3e..d3dd4624 100644 --- a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift @@ -14,7 +14,10 @@ import WeaveDI public protocol AppleOAuthInterface: Sendable { func signIn() async throws -> AppleOAuthPayload - func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload + func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload } // MARK: - Dependencies diff --git a/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift index 3e92d83c..1a70055a 100644 --- a/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift +++ b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift @@ -98,7 +98,10 @@ public actor MockAppleOAuthRepository: AppleOAuthInterface { // MARK: - AppleOAuthRepositoryProtocol Implementation - public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + public func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { // Track call signInCallCount += 1 lastSignInCall = Date() diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index dd517a4b..fa8f820a 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -9,8 +9,14 @@ import WeaveDI public protocol BattleInterface: Sendable { func fetchBattle(battleId: Int) async throws -> BattleDetail - func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult - func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult + func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult + func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult func fetchScenario(battleId: Int) async throws -> BattleScenario func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats func fetchPerspectives( diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index e882e2fb..efd5f2c3 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -32,7 +32,10 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { ) } - public func submitPostVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + public func submitPostVote( + battleId _: Int, + optionId _: Int + ) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } @@ -50,7 +53,10 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { BattlePerspectivePage(items: [], nextCursor: nil, hasNext: false) } - public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + public func submitPreVote( + battleId _: Int, + optionId _: Int + ) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } diff --git a/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift index e81f611d..982444ae 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift @@ -13,7 +13,10 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl public init() {} - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { accessTokenStorage = accessToken refreshTokenStorage = refreshToken } diff --git a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift index ffefaffa..753630dc 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift @@ -9,7 +9,10 @@ import Foundation import WeaveDI public protocol KeychainManaging: Sendable { - func save(accessToken: String, refreshToken: String) + func save( + accessToken: String, + refreshToken: String + ) func saveAccessToken(_ token: String) func clearAccessToken() func saveRefreshToken(_ token: String) diff --git a/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift index 97cb3311..611aa706 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift @@ -55,7 +55,10 @@ public final class MockKeychainManager: KeychainManaging, @unchecked Sendable { // MARK: - KeychainManaging Implementation - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { saveCallCount += 1 switch configuration { diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift index 77be2237..229b4a24 100644 --- a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -24,7 +24,10 @@ public protocol PerspectiveInterface: Sendable { commentId: Int, content: String ) async throws -> PerspectiveCommentMutationResult - func deleteComment(perspectiveId: Int, commentId: Int) async throws + func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws func deletePerspective(perspectiveId: Int) async throws } @@ -68,7 +71,10 @@ public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { PerspectiveCommentMutationResult(commentId: commentId, content: content, updatedAt: nil) } - public func deleteComment(perspectiveId _: Int, commentId _: Int) async throws {} + public func deleteComment( + perspectiveId _: Int, + commentId _: Int + ) async throws {} public func deletePerspective(perspectiveId _: Int) async throws {} } diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift index 3e4baf12..5744485f 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift @@ -9,7 +9,10 @@ public struct PreVoteResult: Equatable, Hashable { public let voteId: Int public let status: PreVoteResultStatus - public init(voteId: Int, status: PreVoteResultStatus) { + public init( + voteId: Int, + status: PreVoteResultStatus + ) { self.voteId = voteId self.status = status } diff --git a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift index c611eb3e..c3fd6a1e 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift @@ -13,7 +13,11 @@ public struct BattlePerspectivePage: Equatable { public let nextCursor: String? public let hasNext: Bool - public init(items: [BattlePerspective], nextCursor: String?, hasNext: Bool) { + public init( + items: [BattlePerspective], + nextCursor: String?, + hasNext: Bool + ) { self.items = items self.nextCursor = nextCursor self.hasNext = hasNext @@ -83,7 +87,12 @@ public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { public var id: Int { optionId } - public init(optionId: Int, label: String?, title: String, stance: String) { + public init( + optionId: Int, + label: String?, + title: String, + stance: String + ) { self.optionId = optionId self.label = label self.title = title diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift index d3b69a35..8be6ad88 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift @@ -24,7 +24,11 @@ public struct CommentAuthor: Equatable, Hashable { public let imageURL: String? public let optionLabel: String? - public init(name: String, imageURL: String? = nil, optionLabel: String? = nil) { + public init( + name: String, + imageURL: String? = nil, + optionLabel: String? = nil + ) { self.name = name self.imageURL = imageURL self.optionLabel = optionLabel @@ -93,7 +97,11 @@ public struct CommentReplyItem: Equatable, Identifiable { } /// 서버 PerspectiveComment 응답을 화면 모델로 변환. - public init(item: PerspectiveComment, parentOption: CommentOption, order: Int) { + public init( + item: PerspectiveComment, + parentOption: CommentOption, + order: Int + ) { let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() self.init( id: id, @@ -229,7 +237,10 @@ public struct CommentItem: Equatable, Identifiable { } /// API 응답 BattlePerspective 를 화면 모델로 변환. - public init(item: BattlePerspective, order: Int) { + public init( + item: BattlePerspective, + order: Int + ) { let optionFallback: CommentOption = item.option.label == "B" ? .b : .a self.init( id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), diff --git a/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift index 84625565..b3439e03 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift @@ -12,7 +12,11 @@ public struct CommentLikeResult: Equatable, Hashable { public let likeCount: Int public let isLiked: Bool - public init(perspectiveId: Int, likeCount: Int, isLiked: Bool) { + public init( + perspectiveId: Int, + likeCount: Int, + isLiked: Bool + ) { self.perspectiveId = perspectiveId self.likeCount = likeCount self.isLiked = isLiked diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift index a7e492be..6943d639 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift @@ -12,7 +12,11 @@ public struct PerspectiveCommentPage: Equatable { public let nextCursor: String? public let hasNext: Bool - public init(items: [PerspectiveComment], nextCursor: String?, hasNext: Bool) { + public init( + items: [PerspectiveComment], + nextCursor: String?, + hasNext: Bool + ) { self.items = items self.nextCursor = nextCursor self.hasNext = hasNext @@ -77,7 +81,11 @@ public struct PerspectiveCommentMutationResult: Equatable, Hashable { public let content: String public let updatedAt: Date? - public init(commentId: Int, content: String, updatedAt: Date?) { + public init( + commentId: Int, + content: String, + updatedAt: Date? + ) { self.commentId = commentId self.content = content self.updatedAt = updatedAt diff --git a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift index f5850030..92dd94ec 100644 --- a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift @@ -15,7 +15,11 @@ public struct BattleTag: Equatable, Identifiable, Hashable { public var id: Int { tagId } - public init(tagId: Int, name: String, type: TagType) { + public init( + tagId: Int, + name: String, + type: TagType + ) { self.tagId = tagId self.name = name self.type = type diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift index 1c22953c..55d100e9 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift @@ -48,7 +48,12 @@ public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { public var id: String { label } - public init(label: String, name: String, stance: String, imageUrl: String) { + public init( + label: String, + name: String, + stance: String, + imageUrl: String + ) { self.label = label self.name = name self.stance = stance @@ -113,7 +118,10 @@ public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { public var id: String { "\(label)-\(nextNodeId)" } - public init(label: String, nextNodeId: Int) { + public init( + label: String, + nextNodeId: Int + ) { self.label = label self.nextNodeId = nextNodeId } diff --git a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift index 94520f66..be4ae3b2 100644 --- a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift +++ b/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift @@ -45,7 +45,10 @@ public struct VoteOption: Equatable, Identifiable, Hashable { public var id: String { label } - public init(label: String, title: String) { + public init( + label: String, + title: String + ) { self.label = label self.title = title } diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index 8404ac75..349dc4fc 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -19,11 +19,17 @@ public struct BattleUseCaseImpl: BattleInterface { return try await battleRepository.fetchBattle(battleId: battleId) } - public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + public func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { return try await battleRepository.submitPreVote(battleId: battleId, optionId: optionId) } - public func submitPostVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + public func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { return try await battleRepository.submitPostVote(battleId: battleId, optionId: optionId) } diff --git a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift index 8655a6e1..c81f7203 100644 --- a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift +++ b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift @@ -24,7 +24,10 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { self.service = service } - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { saveAccessToken(accessToken) saveRefreshToken(refreshToken) } @@ -54,7 +57,10 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { delete(for: Key.refreshToken) } - private func save(_ value: String, for key: String) { + private func save( + _ value: String, + for key: String + ) { let data = Data(value.utf8) let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, diff --git a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift index aebcb57b..cea64063 100644 --- a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift @@ -50,7 +50,10 @@ public struct PerspectiveUseCaseImpl: PerspectiveInterface { ) } - public func deleteComment(perspectiveId: Int, commentId: Int) async throws { + public func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { return try await perspectiveRepository.deleteComment(perspectiveId: perspectiveId, commentId: commentId) } diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index b6e00550..de816b75 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -57,7 +57,10 @@ public struct AuthCoordinator { case presentMainTab } - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift index 0a6382f3..c8f59280 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift @@ -20,7 +20,12 @@ public struct OnBoardingFeature { public let subtitle: String public let imageAsset: ImageAsset - public init(id: Int, title: String, subtitle: String, imageAsset: ImageAsset) { + public init( + id: Int, + title: String, + subtitle: String, + imageAsset: ImageAsset + ) { self.id = id self.title = title self.subtitle = subtitle diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift b/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift index 46b372b5..bde78cdb 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift @@ -14,7 +14,10 @@ public struct OnBoardingPageIndicator: View { private let pageCount: Int private let currentIndex: Int - public init(pageCount: Int, currentIndex: Int) { + public init( + pageCount: Int, + currentIndex: Int + ) { self.pageCount = pageCount self.currentIndex = currentIndex } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 80698c93..1e22b4a8 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -92,7 +92,10 @@ public struct ChatRoomFeature { public var canScrub: Bool { hasFinishedListening } - private func speaker(for script: ScenarioScript, in scenario: BattleScenario) -> ChatSpeaker { + private func speaker( + for script: ScenarioScript, + in scenario: BattleScenario + ) -> ChatSpeaker { switch script.speakerType { case .a: return speaker(label: "A", side: .left, fallbackName: script.speakerName, in: scenario) @@ -353,7 +356,10 @@ extension ChatRoomFeature { } } - private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { switch action { case .fetchScenario: state.isLoadingScenario = true @@ -390,7 +396,10 @@ extension ChatRoomFeature { } } - private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { switch action { case let .scenarioResponse(result): state.isLoadingScenario = false @@ -475,7 +484,10 @@ extension ChatRoomFeature { return .none } - private func handleScopeAction(state: inout State, action: ScopeAction) -> Effect { + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { switch action { case let .customAlert(alertAction): switch alertAction { @@ -504,7 +516,10 @@ extension ChatRoomFeature { } } - private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { switch action { case .dismiss, .requestFinalVote: .none diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index 77ec90ab..26322a1b 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -180,7 +180,10 @@ extension ChatRoomView { } @ViewBuilder - private func bubbleColumn(speaker: ChatSpeaker, messages: [ChatMessage]) -> some View { + private func bubbleColumn( + speaker: ChatSpeaker, + messages: [ChatMessage] + ) -> some View { VStack(alignment: speaker.side == .left ? .leading : .trailing, spacing: 6) { Text(speaker.name) .pretendardFont(family: .SemiBold, size: 13) @@ -197,7 +200,10 @@ extension ChatRoomView { } @ViewBuilder - private func bubble(text: String, side: ChatSpeakerSide) -> some View { + private func bubble( + text: String, + side: ChatSpeakerSide + ) -> some View { Text(text) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral500) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 7d8f7673..20e25cb1 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -398,7 +398,10 @@ extension CommentFeature { /// API 응답(BattleVoteStats) 을 화면 모델 VoteSummary 로 매핑. /// 옵션이 2개 이상이라 가정 — 부족하거나 매핑 실패 시 fallback 유지. - private func makeSummary(from stats: BattleVoteStats, fallback: VoteSummary) -> VoteSummary { + private func makeSummary( + from stats: BattleVoteStats, + fallback: VoteSummary + ) -> VoteSummary { guard stats.options.count >= 2 else { return fallback } let a = stats.options[0] let b = stats.options[1] diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 0bc47df7..bb2e41b1 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -394,7 +394,10 @@ private extension CommentView { } @ViewBuilder - func actionLabel(systemName: String, text: String) -> some View { + func actionLabel( + systemName: String, + text: String + ) -> some View { HStack(spacing: 4) { Image(systemName: systemName) .font(.system(size: 14, weight: .medium)) @@ -405,7 +408,10 @@ private extension CommentView { } @ViewBuilder - func avatar(urlString: String?, fallback: String) -> some View { + func avatar( + urlString: String?, + fallback: String + ) -> some View { if let urlString, let url = URL(string: urlString) { KFImage(url) .placeholder { Color.beige600 } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 59dce217..f33578a9 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -231,7 +231,10 @@ private extension CommentReplyView { .background(option == .a ? Color.beige600 : Color.primary500, in: RoundedRectangle(cornerRadius: 2)) } - func actionLabel(systemName: String, text: String) -> some View { + func actionLabel( + systemName: String, + text: String + ) -> some View { HStack(spacing: 4) { Image(systemName: systemName) .font(.system(size: 14, weight: .medium)) diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 2910b8c2..bb629b2d 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -48,7 +48,10 @@ public struct ChatCoordinator { case dismiss } - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index c115efff..cf027a0b 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -62,7 +62,10 @@ public struct PreVoteFeature { public let id: UUID public let items: [Any] - public init(id: UUID = UUID(), items: [Any]) { + public init( + id: UUID = UUID(), + items: [Any] + ) { self.id = id self.items = items } diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 7feaaeb3..5a1148e1 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -43,7 +43,10 @@ public struct HomeCoordinator { public enum InnerAction: Equatable {} public enum NavigationAction: Equatable {} - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift index 6d3b5812..ebc7270e 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -130,7 +130,10 @@ struct VoteCardView: View { } @ViewBuilder - private func optionButton(index: Int, label: String) -> some View { + private func optionButton( + index: Int, + label: String + ) -> some View { let isSelected = selectedIndex == index Button { @@ -176,7 +179,10 @@ struct VoteCardView: View { } @ViewBuilder - private func resultBarRow(label: String, percentage: Int) -> some View { + private func resultBarRow( + label: String, + percentage: Int + ) -> some View { HStack(spacing: 6) { Text(label) .pretendardFont(family: .Medium, size: 10) diff --git a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift index 496e1328..b399fd6b 100644 --- a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift +++ b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift @@ -23,7 +23,10 @@ struct SplashLogoAnimatedImageView: UIViewRepresentable { return imageView } - func updateUIView(_ imageView: SDAnimatedImageView, context: Context) { + func updateUIView( + _ imageView: SDAnimatedImageView, + context: Context + ) { guard imageView.image !== Self.image else { return } imageView.image = Self.image imageView.startAnimating() diff --git a/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift b/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift index 88c34769..565ff081 100644 --- a/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift +++ b/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift @@ -17,7 +17,10 @@ public struct PretendardFont: ViewModifier { } public extension View { - func pretendardFont(family: PretendardFontFamily, size: CGFloat) -> some View { + func pretendardFont( + family: PretendardFontFamily, + size: CGFloat + ) -> some View { return self.modifier(PretendardFont(family: family, size: size)) } @@ -27,14 +30,20 @@ public extension View { } public extension UIFont { - static func pretendardFontFamily(family: PretendardFontFamily, size: CGFloat) -> UIFont { + static func pretendardFontFamily( + family: PretendardFontFamily, + size: CGFloat + ) -> UIFont { let fontName = "PretendardVariable-\(family)" return UIFont(name: fontName, size: size) ?? UIFont.systemFont(ofSize: size, weight: .regular) } } public extension Font { - static func pretendardFontFamily(family: PretendardFontFamily, size: CGFloat) -> Font{ + static func pretendardFontFamily( + family: PretendardFontFamily, + size: CGFloat + ) -> Font{ let font = Font.custom("PretendardVariable-\(family)", size: size) return font } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift index 34c1a265..dcf1c180 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift @@ -8,7 +8,10 @@ import SwiftUI public extension Color { - init(hex: String, alpha: Double = 1.0) { + init( + hex: String, + alpha: Double = 1.0 + ) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") var rgb: UInt64 = 0 diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index dd1dc6ff..d9337f60 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -8,7 +8,10 @@ import UIKit public extension UIColor { - convenience init(hex: String, alpha: Double = 1.0) { + convenience init( + hex: String, + alpha: Double = 1.0 + ) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index f6b53faa..d746e9b9 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -271,7 +271,10 @@ struct CustomConfirmationPopup: View { } @ViewBuilder - private func reasonColumn(_ reasons: [ReportReason], fillSpacer: Bool = false) -> some View { + private func reasonColumn( + _ reasons: [ReportReason], + fillSpacer: Bool = false + ) -> some View { VStack(alignment: .leading, spacing: 16) { ForEach(reasons) { reason in reasonRow(reason) diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift index 2a540198..9ad66625 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift @@ -15,7 +15,10 @@ public enum CTAButtonVariant: Sendable { } public extension CTAButtonVariant { - func backgroundColor(isEnabled: Bool, isPressed: Bool = false) -> Color { + func backgroundColor( + isEnabled: Bool, + isPressed: Bool = false + ) -> Color { switch self { case .primary: guard isEnabled else { return ComponentToken.Button.Primary.Background.disabled } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift index 367d1160..69d474d5 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift @@ -19,5 +19,8 @@ public struct ShareSheet: UIViewControllerRepresentable { UIActivityViewController(activityItems: items, applicationActivities: nil) } - public func updateUIViewController(_: UIActivityViewController, context _: Context) {} + public func updateUIViewController( + _: UIActivityViewController, + context _: Context + ) {} } From ef7fe6aa3c85ee7d27ce87bd20a1a28a8e96eec6 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 24 May 2026 00:28:17 +0900 Subject: [PATCH 22/42] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=95=88=EB=90=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=E2=80=94=20CommentFeature=20=EC=97=90=20fetchMyPerspective=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진입 시 perspectiveId 가 nil 이라 sendTapped 가 항상 막혀 댓글 작성 불가능했음 - CommentFeature.onAppear 의 merge 에 fetchMyPerspective 추가 - AsyncAction.fetchMyPerspective / InnerAction.myPerspectiveResponse / CancelID.fetchMyPerspective 신설 - 응답 성공 시 state.perspectiveId = perspective?.perspectiveId - 본인이 작성한 perspective 가 없으면 nil 유지 → 댓글 작성 비활성 (의도된 동작) --- .../Comment/Reducer/CommentFeature.swift | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 20e25cb1..cddd1ee5 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -11,9 +11,9 @@ import Foundation import ComposableArchitecture import DesignSystem import DomainInterface -import UseCase import Entity import LogMacro +import UseCase @Reducer public struct CommentFeature { @@ -86,6 +86,7 @@ public struct CommentFeature { public enum AsyncAction: Equatable { case fetchBattle + case fetchMyPerspective case fetchVoteStats case fetchPerspectives(reset: Bool) case toggleLike(commentId: Int, currentlyLiked: Bool) @@ -94,6 +95,7 @@ public struct CommentFeature { public enum InnerAction: Equatable { case battleResponse(Result) + case myPerspectiveResponse(Result) case voteStatsResponse(Result) case perspectivesResponse(Result, reset: Bool) case likeResponse(Result) @@ -112,6 +114,7 @@ public struct CommentFeature { nonisolated enum CancelID: Hashable { case fetchBattle + case fetchMyPerspective case fetchVoteStats case fetchPerspectives case toggleLike @@ -166,6 +169,7 @@ extension CommentFeature { case .onAppear: return .merge( .send(.async(.fetchBattle)), + .send(.async(.fetchMyPerspective)), .send(.async(.fetchVoteStats)), .send(.async(.fetchPerspectives(reset: true))) ) @@ -274,6 +278,17 @@ extension CommentFeature { } .cancellable(id: CancelID.fetchBattle, cancelInFlight: true) + case .fetchMyPerspective: + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchMyPerspective(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.myPerspectiveResponse(result))) + } + .cancellable(id: CancelID.fetchMyPerspective, cancelInFlight: true) + case .fetchVoteStats: state.isLoadingStats = true let battleId = state.battleId @@ -347,6 +362,15 @@ extension CommentFeature { } return .none + case let .myPerspectiveResponse(result): + switch result { + case let .success(perspective): + state.perspectiveId = perspective?.perspectiveId + case let .failure(error): + Log.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)") + } + return .none + case let .voteStatsResponse(result): state.isLoadingStats = false switch result { From bc2443de079c71291cfb9ea66ffa8cc350ee2f47 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 24 May 2026 03:19:18 +0900 Subject: [PATCH 23/42] =?UTF-8?q?feat:=20Perspective=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=84=EC=9A=A9=20PerspectiveError=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity/Error/PerspectiveError.swift 신규 - LocalizedError + Equatable + .from(_:) 컨버터 - PerspectiveRepositoryImpl - fetchPerspective / deletePerspective 의 throw 를 PerspectiveError 로 교체 - 댓글 CRUD (fetchLabeled / create / update / deleteComment) 는 CommentError 유지 - PreVoteFeature.deleteMyPerspectiveResponse / mapError 를 PerspectiveError 로 - CommentReplyFeature.parentResponse / fetchParent mapError 를 PerspectiveError 로 --- .../PerspectiveRepositoryImpl.swift | 6 ++-- .../Sources/Error/PerspectiveError.swift | 35 +++++++++++++++++++ .../Reducer/CommentReplyFeature.swift | 6 ++-- .../Sources/Vote/Reducer/PreVoteFeature.swift | 6 ++-- 4 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Error/PerspectiveError.swift diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift index b5933191..61c24cb7 100644 --- a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -30,9 +30,9 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S ) guard let data = dto.data else { - let message = dto.error?.message ?? "댓글 상세 응답이 비어 있습니다" + let message = dto.error?.message ?? "perspective 상세 응답이 비어 있습니다" Log.error("[PerspectiveRepositoryImpl] empty detail payload: \(message)") - throw CommentError.backendError(message) + throw PerspectiveError.backendError(message) } return data.toDomain() @@ -116,7 +116,7 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S if dto.statusCode >= 400 { let message = dto.error?.message ?? "perspective 삭제 실패" - throw BattleError.backendError(message) + throw PerspectiveError.backendError(message) } } } diff --git a/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift b/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift new file mode 100644 index 00000000..a99556f9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift @@ -0,0 +1,35 @@ +// +// PerspectiveError.swift +// Entity +// + +import Foundation + +public enum PerspectiveError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): return "네트워크 오류: \(message)" + case let .decodingError(message): return "데이터 파싱 오류: \(message)" + case .noData: return "perspective 데이터가 없습니다" + case .unauthorized: return "권한이 없습니다" + case let .backendError(message): return "perspective 처리 실패: \(message)" + case let .serverError(code): return "서버 오류 (코드: \(code))" + case let .unknown(message): return "알 수 없는 오류: \(message)" + } + } +} + +public extension PerspectiveError { + static func from(_ error: Error) -> PerspectiveError { + if let perspectiveError = error as? PerspectiveError { return perspectiveError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index 2646c0f9..da7e091e 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -15,9 +15,9 @@ import Foundation import ComposableArchitecture import DomainInterface -import UseCase import Entity import LogMacro +import UseCase @Reducer public struct CommentReplyFeature { @@ -79,7 +79,7 @@ public struct CommentReplyFeature { } public enum InnerAction: Equatable { - case parentResponse(Result) + case parentResponse(Result) case repliesResponse(Result, reset: Bool) case createResponse(Result) case updateResponse(Result, commentId: Int) @@ -204,7 +204,7 @@ extension CommentReplyFeature { let result = await Result { try await repository.fetchPerspective(perspectiveId: pid) } - .mapError(CommentError.from) + .mapError(PerspectiveError.from) return await send(.inner(.parentResponse(result))) } .cancellable(id: CancelID.fetchParent, cancelInFlight: true) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index cf027a0b..e3cf20b1 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -11,9 +11,9 @@ import UIKit import ComposableArchitecture import DesignSystem import DomainInterface -import UseCase import Entity import LogMacro +import UseCase @Reducer public struct PreVoteFeature { @@ -105,7 +105,7 @@ public struct PreVoteFeature { public enum InnerAction: Equatable { case battleDetailResponse(Result) case myPerspectiveResponse(Result) - case deleteMyPerspectiveResponse(Result) + case deleteMyPerspectiveResponse(Result) case preVoteResponse(Result) case sharePrepared(ShareItem) case postVoteResponse(Result) @@ -241,7 +241,7 @@ extension PreVoteFeature { try await repository.deletePerspective(perspectiveId: perspectiveId) return EmptyResult() } - .mapError(BattleError.from) + .mapError(PerspectiveError.from) return await send(.inner(.deleteMyPerspectiveResponse(result))) } .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) From 6438cbdbf52a3d2c75e4f9f165da38690a070dae Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:16:39 +0900 Subject: [PATCH 24/42] Align perspective creation decoding with backend payload Decode the createPerspective response as the lightweight creation payload returned by the server instead of requiring a full BattlePerspective object. Rejected: Make BattlePerspectiveDTO fields optional | that would weaken list/detail decoding and hide contract mismatches. #4 --- .../Sources/Battle/DTO/BattlePerspectiveDataDTO.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift index cea175ce..64b46e64 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift @@ -37,4 +37,11 @@ public struct BattlePerspectiveOptionDTO: Decodable { public let stance: String? } +public struct CreatePerspectiveDataDTO: Decodable { + public let perspectiveId: Int + public let status: String? + public let createdAt: String? +} + public typealias BattlePerspectivePageResponseDTO = BaseResponseDTO +public typealias CreatePerspectiveResponseDTO = BaseResponseDTO From ba1c6d1d05bf8090a9fcf389ae5fd5a8a2006b82 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:17:24 +0900 Subject: [PATCH 25/42] Return created perspective after lightweight create response After the create endpoint confirms success, fetch my perspective to preserve the BattleInterface createPerspective return contract without decoding the create response as a full resource. Rejected: Change createPerspective to return Void | callers and interface semantics expect the created perspective value. #4 --- .../Sources/Battle/BattleRepositoryImpl.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index e98ede4a..985d7a8a 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -119,20 +119,26 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { content: String, optionId: Int ) async throws -> BattlePerspective { - let dto: BaseResponseDTO = try await provider.request( + let dto: CreatePerspectiveResponseDTO = try await provider.request( .createPerspective( battleId: battleId, body: CreatePerspectiveRequest(content: content, optionId: optionId) ) ) - guard let data = dto.data else { + guard dto.data != nil else { let message = dto.error?.message ?? "댓글 작성 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty createPerspective payload: \(message)") throw BattleError.backendError(message) } - return data.toDomain() + guard let perspective = try await fetchMyPerspective(battleId: battleId) else { + let message = "작성한 댓글을 다시 조회할 수 없습니다" + Log.error("[BattleRepositoryImpl] empty created perspective payload: \(message)") + throw BattleError.backendError(message) + } + + return perspective } public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { From 965455f38a4dfecbfd0288356d7fe0d378175db6 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:19:16 +0900 Subject: [PATCH 26/42] Route perspective list and like APIs through domain contracts Add perspective mutation and like endpoints through Service, Repository, DomainInterface, and UseCase while moving battle perspective request/query shapes into explicit service models. Rejected: Keep default perspective repository inline in PerspectiveInterface | the expanded interface made the file noisy and harder to maintain. #4 --- .../Sources/Perspective/PerspectiveAPI.swift | 3 + .../Comment/DTO/CommentLikeDataDTO.swift | 3 +- .../Comment/Mapper/CommentLikeDataDTO+.swift | 2 +- .../Sources/Battle/BattleRepositoryImpl.swift | 4 +- .../PerspectiveRepositoryImpl.swift | 53 ++++++++++++++ .../Sources/Battle/BattleService.swift | 13 ---- .../Battle/CreatePerspectiveRequest.swift | 21 ++++++ .../Battle/PerspectivesQueryRequest.swift | 6 +- .../Perspective/PerspectiveService.swift | 32 +++++++-- .../Sources/Battle/BattleInterface.swift | 2 +- .../Battle/DefaultBattleRepositoryImpl.swift | 2 +- .../DefaultPerspectiveRepositoryImpl.swift | 71 +++++++++++++++++++ .../Perspective/PerspectiveInterface.swift | 52 ++------------ .../Sources/Battle/BattleUseCase.swift | 4 +- .../Perspective/PerspectiveUseCase.swift | 16 +++++ 15 files changed, 205 insertions(+), 79 deletions(-) create mode 100644 Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift diff --git a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift index acf4e1e8..aaa426c8 100644 --- a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift +++ b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift @@ -11,11 +11,14 @@ public enum PerspectiveAPI { case createComment(perspectiveId: Int) case updateComment(perspectiveId: Int, commentId: Int) case deleteComment(perspectiveId: Int, commentId: Int) + case likes(perspectiveId: Int) public var description: String { switch self { case let .detail(perspectiveId): return "\(perspectiveId)" + case let .likes(perspectiveId): + return "\(perspectiveId)/likes" case let .listLabeledComments(perspectiveId): return "\(perspectiveId)/comments/labeled" case let .createComment(perspectiveId): diff --git a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift index 311af7a9..d084a38d 100644 --- a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift +++ b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift @@ -8,7 +8,8 @@ import Foundation public struct CommentLikeDataDTO: Decodable { public let perspectiveId: Int public let likeCount: Int - public let isLiked: Bool + /// perspective 좋아요 응답에는 isLiked 가 없어 옵셔널. + public let isLiked: Bool? } public typealias CommentLikeResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift index 01db98af..3ad4e2ed 100644 --- a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift +++ b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift @@ -11,7 +11,7 @@ public extension CommentLikeDataDTO { CommentLikeResult( perspectiveId: perspectiveId, likeCount: likeCount, - isLiked: isLiked + isLiked: isLiked ?? false ) } } diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 985d7a8a..195e7617 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -90,7 +90,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { battleId: Int, cursor: String?, size: Int?, - optionLabel: String?, + optionId: Int?, sort: BattlePerspectiveSort? ) async throws -> BattlePerspectivePage { let dto: BattlePerspectivePageResponseDTO = try await provider.request( @@ -99,7 +99,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { query: PerspectivesQueryRequest( cursor: cursor, size: size, - optionLabel: optionLabel, + optionId: optionId, sort: sort?.queryValue ) ) diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift index 61c24cb7..15558aea 100644 --- a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -109,6 +109,17 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S } } + public func updatePerspective(perspectiveId: Int, content: String) async throws { + let dto: BaseResponseDTO = try await provider.request( + .updatePerspective(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "perspective 수정 실패" + throw PerspectiveError.backendError(message) + } + } + public func deletePerspective(perspectiveId: Int) async throws { let dto: BaseResponseDTO = try await provider.request( .deletePerspective(perspectiveId: perspectiveId) @@ -119,6 +130,48 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S throw PerspectiveError.backendError(message) } } + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .likePerspective(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: true) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .unlikePerspective(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 취소 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty unlike payload: \(message)") + throw CommentError.backendError(message) + } + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: false) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .fetchPerspectiveLikes(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 수 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } } public struct EmptyDTO: Decodable {} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 96057566..d2f65cbc 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -10,19 +10,6 @@ import Foundations import AsyncMoya -public struct CreatePerspectiveRequest: Encodable { - public let content: String - public let optionId: Int - - public init( - content: String, - optionId: Int - ) { - self.content = content - self.optionId = optionId - } -} - public enum BattleService { case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) diff --git a/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift new file mode 100644 index 00000000..e185f68f --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift @@ -0,0 +1,21 @@ +// +// CreatePerspectiveRequest.swift +// Service +// +// Created by Wonji Suh on 5/24/26. +// + +import Foundation + +public struct CreatePerspectiveRequest: Encodable { + public let content: String + public let optionId: Int + + public init( + content: String, + optionId: Int + ) { + self.content = content + self.optionId = optionId + } +} diff --git a/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift index 9e92fbb7..ca617042 100644 --- a/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift +++ b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift @@ -10,18 +10,18 @@ import Foundation public struct PerspectivesQueryRequest: Encodable { public let cursor: String? public let size: Int? - public let optionLabel: String? + public let optionId: Int? public let sort: String? public init( cursor: String? = nil, size: Int? = nil, - optionLabel: String? = nil, + optionId: Int? = nil, sort: String? = nil ) { self.cursor = cursor self.size = size - self.optionLabel = optionLabel + self.optionId = optionId self.sort = sort } } diff --git a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift index 92a92553..740e2e01 100644 --- a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift +++ b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift @@ -21,7 +21,11 @@ public enum PerspectiveService { case createComment(perspectiveId: Int, body: PerspectiveCommentBody) case updateComment(perspectiveId: Int, commentId: Int, body: PerspectiveCommentBody) case deleteComment(perspectiveId: Int, commentId: Int) + case updatePerspective(perspectiveId: Int, body: PerspectiveCommentBody) case deletePerspective(perspectiveId: Int) + case likePerspective(perspectiveId: Int) + case unlikePerspective(perspectiveId: Int) + case fetchPerspectiveLikes(perspectiveId: Int) } extension PerspectiveService: BaseTargetType { @@ -41,8 +45,16 @@ extension PerspectiveService: BaseTargetType { return PerspectiveAPI.updateComment(perspectiveId: perspectiveId, commentId: commentId).description case let .deleteComment(perspectiveId, commentId): return PerspectiveAPI.deleteComment(perspectiveId: perspectiveId, commentId: commentId).description + case let .updatePerspective(perspectiveId, _): + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description case let .deletePerspective(perspectiveId): return PerspectiveAPI.detail(perspectiveId: perspectiveId).description + case let .likePerspective(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .unlikePerspective(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .fetchPerspectiveLikes(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description } } @@ -50,14 +62,16 @@ extension PerspectiveService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail, .listLabeledComments: - .get - case .createComment: - .post + case .detail, .listLabeledComments, .fetchPerspectiveLikes: + return .get + case .createComment, .likePerspective: + return .post case .updateComment: - .put - case .deleteComment, .deletePerspective: - .delete + return .put + case .updatePerspective: + return .patch + case .deleteComment, .deletePerspective, .unlikePerspective: + return .delete } } @@ -74,10 +88,14 @@ extension PerspectiveService: BaseTargetType { return body.toDictionary case let .updateComment(_, _, body): return body.toDictionary + case let .updatePerspective(_, body): + return body.toDictionary case .deleteComment: return nil case .deletePerspective: return nil + case .likePerspective, .unlikePerspective, .fetchPerspectiveLikes: + return nil } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index fa8f820a..2052255e 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -23,7 +23,7 @@ public protocol BattleInterface: Sendable { battleId: Int, cursor: String?, size: Int?, - optionLabel: String?, + optionId: Int?, sort: BattlePerspectiveSort? ) async throws -> BattlePerspectivePage func createPerspective( diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index efd5f2c3..1df166e8 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -47,7 +47,7 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { battleId _: Int, cursor _: String?, size _: Int?, - optionLabel _: String?, + optionId _: Int?, sort _: BattlePerspectiveSort? ) async throws -> BattlePerspectivePage { BattlePerspectivePage(items: [], nextCursor: nil, hasNext: false) diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift new file mode 100644 index 00000000..04847d5a --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift @@ -0,0 +1,71 @@ +// +// DefaultPerspectiveRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 6/3/26. +// + +import Entity +import Foundation + +public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: BattlePerspectiveUser(userTag: "", nickname: "", characterType: "", characterImageUrl: nil), + option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), + content: "", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: false, + createdAt: nil + ) + } + + public func fetchLabeledComments( + perspectiveId _: Int, + cursor _: String?, + size _: Int? + ) async throws -> PerspectiveCommentPage { + PerspectiveCommentPage(items: [], nextCursor: nil, hasNext: false) + } + + public func createComment( + perspectiveId _: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: 0, content: content, updatedAt: nil) + } + + public func updateComment( + perspectiveId _: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: commentId, content: content, updatedAt: nil) + } + + public func deleteComment( + perspectiveId _: Int, + commentId _: Int + ) async throws {} + + public func updatePerspective(perspectiveId _: Int, content _: String) async throws {} + + public func deletePerspective(perspectiveId _: Int) async throws {} + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: true) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: false) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: false) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift index 229b4a24..8ab7545f 100644 --- a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -28,55 +28,11 @@ public protocol PerspectiveInterface: Sendable { perspectiveId: Int, commentId: Int ) async throws + func updatePerspective(perspectiveId: Int, content: String) async throws func deletePerspective(perspectiveId: Int) async throws -} - -public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { - public init() {} - - public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { - BattlePerspective( - perspectiveId: perspectiveId, - user: BattlePerspectiveUser(userTag: "", nickname: "", characterType: "", characterImageUrl: nil), - option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), - content: "", - likeCount: 0, - commentCount: 0, - isLiked: false, - isMyPerspective: false, - createdAt: nil - ) - } - - public func fetchLabeledComments( - perspectiveId _: Int, - cursor _: String?, - size _: Int? - ) async throws -> PerspectiveCommentPage { - PerspectiveCommentPage(items: [], nextCursor: nil, hasNext: false) - } - - public func createComment( - perspectiveId _: Int, - content: String - ) async throws -> PerspectiveCommentMutationResult { - PerspectiveCommentMutationResult(commentId: 0, content: content, updatedAt: nil) - } - - public func updateComment( - perspectiveId _: Int, - commentId: Int, - content: String - ) async throws -> PerspectiveCommentMutationResult { - PerspectiveCommentMutationResult(commentId: commentId, content: content, updatedAt: nil) - } - - public func deleteComment( - perspectiveId _: Int, - commentId _: Int - ) async throws {} - - public func deletePerspective(perspectiveId _: Int) async throws {} + func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult + func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult + func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult } public struct PerspectiveRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index 349dc4fc..282e0c3c 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -45,14 +45,14 @@ public struct BattleUseCaseImpl: BattleInterface { battleId: Int, cursor: String?, size: Int?, - optionLabel: String?, + optionId: Int?, sort: BattlePerspectiveSort? ) async throws -> BattlePerspectivePage { return try await battleRepository.fetchPerspectives( battleId: battleId, cursor: cursor, size: size, - optionLabel: optionLabel, + optionId: optionId, sort: sort ) } diff --git a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift index cea64063..347894e9 100644 --- a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift @@ -57,9 +57,25 @@ public struct PerspectiveUseCaseImpl: PerspectiveInterface { return try await perspectiveRepository.deleteComment(perspectiveId: perspectiveId, commentId: commentId) } + public func updatePerspective(perspectiveId: Int, content: String) async throws { + return try await perspectiveRepository.updatePerspective(perspectiveId: perspectiveId, content: content) + } + public func deletePerspective(perspectiveId: Int) async throws { return try await perspectiveRepository.deletePerspective(perspectiveId: perspectiveId) } + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.likePerspective(perspectiveId: perspectiveId) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.unlikePerspective(perspectiveId: perspectiveId) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } } extension PerspectiveUseCaseImpl: DependencyKey { From 9eda804d72dbb93f35f001aab81c506c0e440f04 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:19:35 +0900 Subject: [PATCH 27/42] Wire comment screens to perspective actions Connect comment and reply screens to perspective creation, edit, delete, filtering, liking, menu sheets, skeleton states, and reply navigation using the expanded domain surface. Rejected: Continue using comment like endpoints for parent perspective cards | it updates the wrong backend resource. #4 --- .../Sources/Home/Battle/VoteSummary.swift | 6 + .../Entity/Sources/Home/Comment/Comment.swift | 12 ++ .../Comment/Reducer/CommentFeature.swift | 197 +++++++++++++++--- .../Sources/Comment/View/CommentView.swift | 157 ++++++++++---- .../View/Components/CommentSkeletonView.swift | 46 ++++ .../Reducer/CommentReplyFeature.swift | 117 ++++++++++- .../CommentReply/View/CommentReplyView.swift | 57 +++-- .../Components/CommentReplySkeletonView.swift | 73 +++++++ .../Coordinator/Reducer/ChatCoordinator.swift | 4 + .../UI/ActionSheet/BottomActionSheet.swift | 73 +++++++ .../Alert/CustomPopup/CustomAlertState.swift | 20 ++ 11 files changed, 680 insertions(+), 82 deletions(-) create mode 100644 Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift create mode 100644 Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift index 9a3cb92c..187e1360 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift @@ -36,20 +36,26 @@ public struct VoteSummary: Equatable { } public struct VoteOptionSummary: Equatable { + public var optionId: Int public var label: String public var title: String public var representative: String + public var imageUrl: String? public var percentage: Double public init( + optionId: Int = 0, label: String, title: String, representative: String, + imageUrl: String? = nil, percentage: Double ) { + self.optionId = optionId self.label = label self.title = title self.representative = representative + self.imageUrl = imageUrl self.percentage = percentage } } diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift index 8be6ad88..f5e51924 100644 --- a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift +++ b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift @@ -67,32 +67,38 @@ public struct CommentReplyItem: Equatable, Identifiable { public let id: UUID public var commentId: Int? public var author: String + public var authorImageURL: String? public var timeAgo: String public var option: CommentOption public var content: String public var likeCount: Int public var isLiked: Bool + public var isMine: Bool public var createdOrder: Int public init( id: UUID = UUID(), commentId: Int? = nil, author: String, + authorImageURL: String? = nil, timeAgo: String, option: CommentOption, content: String, likeCount: Int, isLiked: Bool = false, + isMine: Bool = false, createdOrder: Int ) { self.id = id self.commentId = commentId self.author = author + self.authorImageURL = authorImageURL self.timeAgo = timeAgo self.option = option self.content = content self.likeCount = likeCount self.isLiked = isLiked + self.isMine = isMine self.createdOrder = createdOrder } @@ -107,11 +113,13 @@ public struct CommentReplyItem: Equatable, Identifiable { id: id, commentId: item.commentId, author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, timeAgo: Self.relativeTimeString(from: item.createdAt), option: parentOption, content: item.content, likeCount: item.likeCount, isLiked: item.isLiked, + isMine: item.isMine, createdOrder: order ) } @@ -206,6 +214,7 @@ public struct CommentItem: Equatable, Identifiable { public var replyCount: Int public var likeCount: Int public var isLiked: Bool + public var isMine: Bool public var createdOrder: Int public init( @@ -220,6 +229,7 @@ public struct CommentItem: Equatable, Identifiable { replyCount: Int, likeCount: Int, isLiked: Bool = false, + isMine: Bool = false, createdOrder: Int ) { self.id = id @@ -233,6 +243,7 @@ public struct CommentItem: Equatable, Identifiable { self.replyCount = replyCount self.likeCount = likeCount self.isLiked = isLiked + self.isMine = isMine self.createdOrder = createdOrder } @@ -254,6 +265,7 @@ public struct CommentItem: Equatable, Identifiable { replyCount: item.commentCount, likeCount: item.likeCount, isLiked: item.isLiked, + isMine: item.isMyPerspective, createdOrder: order ) } diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index cddd1ee5..a45d1a72 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -23,6 +23,8 @@ public struct CommentFeature { public struct State: Equatable { public var battleId: Int = 0 public var perspectiveId: Int? + /// 관점 등록 시 사용할 내 옵션(투표한 진영) optionId. + public var myOptionId: Int? public var title: String = "" public var voteSummary: VoteSummary = .mock public var isLoadingStats: Bool = false @@ -33,7 +35,20 @@ public struct CommentFeature { public var selectedFilter: CommentFilter = .all public var selectedSort: CommentSort = .popular public var reportTargetCommentID: UUID? + /// "…" 메뉴(수정/삭제 또는 신고)를 띄울 대상 댓글 id. + public var menuTargetCommentID: UUID? + /// 수정 중인 관점 perspectiveId (입력창이 수정 모드). + public var editingPerspectiveId: Int? + /// 삭제 확인 알럿의 대상 perspectiveId. + public var deleteTargetPerspectiveId: Int? @Presents public var customAlert: CustomAlertState? + + /// 메뉴 대상 댓글(있으면 confirmationDialog 표시). + public var menuTargetComment: CommentItem? { + guard let id = menuTargetCommentID else { return nil } + return comments.first { $0.id == id } + } + public var comments: [CommentItem] = [] public var commentText: String = "" @@ -51,7 +66,6 @@ public struct CommentFeature { public var isSendEnabled: Bool { !commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSubmitting - && perspectiveId != nil } public init(battleId: Int = 0) { @@ -72,6 +86,7 @@ public struct CommentFeature { public enum View { case onAppear case backButtonTapped + case forwardTapped case shareTapped case filterTapped(CommentFilter) case sortTapped(CommentSort) @@ -80,6 +95,10 @@ public struct CommentFeature { case reportButtonTapped(UUID) case reportConfirmTapped(UUID) case reportPopupDismissed + case menuDismissed + case editTapped(UUID) + case deleteTapped(UUID) + case reportTapped(UUID) case likeTapped(UUID) case sendTapped } @@ -90,7 +109,10 @@ public struct CommentFeature { case fetchVoteStats case fetchPerspectives(reset: Bool) case toggleLike(commentId: Int, currentlyLiked: Bool) - case createComment(perspectiveId: Int, content: String) + case createComment(content: String) + case updatePerspective(perspectiveId: Int, content: String) + case deletePerspective(perspectiveId: Int) + case fetchPerspectiveLikes(perspectiveId: Int) } public enum InnerAction: Equatable { @@ -99,7 +121,9 @@ public struct CommentFeature { case voteStatsResponse(Result) case perspectivesResponse(Result, reset: Bool) case likeResponse(Result) - case createCommentResponse(Result) + case createCommentResponse(Result) + case mutationFinished + case perspectiveLikesResponse(Result) } @CasePathable @@ -177,6 +201,11 @@ extension CommentFeature { case .backButtonTapped: return .send(.delegate(.dismiss)) + case .forwardTapped: + // 앱바 우측 > : 첫 댓글의 대댓글(CommentReplyView) 화면으로 이동 + guard let comment = state.comments.first else { return .none } + return .send(.delegate(.openReply(comment))) + case .shareTapped: return .none @@ -186,6 +215,34 @@ extension CommentFeature { return .send(.delegate(.openReply(comment))) case let .reportButtonTapped(id): + // "…" 탭 → 메뉴 표시 (내 글: 수정/삭제, 남 글: 신고) + state.menuTargetCommentID = id + return .none + + case .menuDismissed: + state.menuTargetCommentID = nil + return .none + + case let .editTapped(id): + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.editingPerspectiveId = pid + state.commentText = comment.content + return .none + + case let .deleteTapped(id): + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.deleteTargetPerspectiveId = pid + state.customAlert = .deletePerspective() + return .none + + case let .reportTapped(id): + state.menuTargetCommentID = nil state.reportTargetCommentID = id state.customAlert = .report() return .none @@ -221,13 +278,14 @@ extension CommentFeature { case .sendTapped: let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, - let pid = state.perspectiveId, - !state.isSubmitting - else { return .none } + guard !text.isEmpty, !state.isSubmitting else { return .none } state.isSubmitting = true state.commentText = "" - return .send(.async(.createComment(perspectiveId: pid, content: text))) + if let editId = state.editingPerspectiveId { + state.editingPerspectiveId = nil + return .send(.async(.updatePerspective(perspectiveId: editId, content: text))) + } + return .send(.async(.createComment(content: text))) } } @@ -241,21 +299,26 @@ extension CommentFeature { case let .presented(customAlertAction): switch customAlertAction { case .confirmTapped: - guard let id = state.reportTargetCommentID else { - state.customAlert = nil - return .none - } state.customAlert = nil - return .send(.view(.reportConfirmTapped(id))) + if let pid = state.deleteTargetPerspectiveId { + state.deleteTargetPerspectiveId = nil + return .send(.async(.deletePerspective(perspectiveId: pid))) + } + if let id = state.reportTargetCommentID { + return .send(.view(.reportConfirmTapped(id))) + } + return .none case .cancelTapped: state.reportTargetCommentID = nil + state.deleteTargetPerspectiveId = nil state.customAlert = nil return .none } case .dismiss: state.reportTargetCommentID = nil + state.deleteTargetPerspectiveId = nil state.customAlert = nil return .none } @@ -303,9 +366,17 @@ extension CommentFeature { case let .fetchPerspectives(reset): state.isLoadingComments = true + // 초기/정렬/필터/등록 후 reset 시 리스트를 비워 스켈레톤이 노출되도록 한다. + if reset { state.comments = [] } let battleId = state.battleId let cursor = reset ? nil : state.nextCursor - let optionLabel = state.selectedFilter.queryLabel + let optionId: Int? = { + switch state.selectedFilter { + case .all: return nil + case .optionA: return state.voteSummary.optionA.optionId > 0 ? state.voteSummary.optionA.optionId : nil + case .optionB: return state.voteSummary.optionB.optionId > 0 ? state.voteSummary.optionB.optionId : nil + } + }() let sort = state.selectedSort.perspectiveSort return .run { [repository = battleUseCase] send in let result = await Result { @@ -313,7 +384,7 @@ extension CommentFeature { battleId: battleId, cursor: cursor, size: 20, - optionLabel: optionLabel, + optionId: optionId, sort: sort ) } @@ -323,12 +394,13 @@ extension CommentFeature { .cancellable(id: CancelID.fetchPerspectives, cancelInFlight: true) case let .toggleLike(commentId, currentlyLiked): - return .run { [repository = commentUseCase] send in + // commentId 는 관점(perspective) id. 관점 좋아요는 perspectives/{id}/likes 사용. + return .run { [repository = perspectiveUseCase] send in let result = await Result { if currentlyLiked { - try await repository.unlikeComment(commentId: commentId) + try await repository.unlikePerspective(perspectiveId: commentId) } else { - try await repository.likeComment(commentId: commentId) + try await repository.likePerspective(perspectiveId: commentId) } } .mapError(CommentError.from) @@ -336,15 +408,39 @@ extension CommentFeature { } .cancellable(id: CancelID.toggleLike, cancelInFlight: false) - case let .createComment(perspectiveId, content): - return .run { [repository = perspectiveUseCase] send in + case let .createComment(content): + let battleId = state.battleId + // 관점 등록 optionId: 내 관점/투표 진영 > 첫 옵션 순으로 결정. + let optionId = state.myOptionId ?? state.voteSummary.optionA.optionId + return .run { [battle = battleUseCase] send in let result = await Result { - try await repository.createComment(perspectiveId: perspectiveId, content: content) + try await battle.createPerspective(battleId: battleId, content: content, optionId: optionId) } - .mapError(CommentError.from) + .mapError(BattleError.from) return await send(.inner(.createCommentResponse(result))) } .cancellable(id: CancelID.createComment, cancelInFlight: false) + + case let .updatePerspective(perspectiveId, content): + return .run { [repository = perspectiveUseCase] send in + try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + await send(.inner(.mutationFinished)) + } + + case let .deletePerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + try? await repository.deletePerspective(perspectiveId: perspectiveId) + await send(.inner(.mutationFinished)) + } + + case let .fetchPerspectiveLikes(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + .mapError(CommentError.from) + return await send(.inner(.perspectiveLikesResponse(result))) + } } } @@ -357,6 +453,27 @@ extension CommentFeature { switch result { case let .success(detail): state.title = detail.battleInfo.title + // 필터 탭/아바타에 쓸 옵션 title·대표를 배틀 상세에서 채운다. + // (vote-stats 가 비어 있어도 실제 A/B title 이 노출되도록) + let options = detail.battleInfo.options + if options.count >= 2 { + state.voteSummary.optionA.optionId = options[0].optionId + state.voteSummary.optionA.title = options[0].title + state.voteSummary.optionA.representative = options[0].representative + state.voteSummary.optionA.imageUrl = options[0].imageUrl + state.voteSummary.optionB.optionId = options[1].optionId + state.voteSummary.optionB.title = options[1].title + state.voteSummary.optionB.representative = options[1].representative + state.voteSummary.optionB.imageUrl = options[1].imageUrl + } + // 내 관점이 아직 없으면 투표 진영(userVoteStatus)으로 등록 optionId 결정. + if state.myOptionId == nil, options.count >= 2 { + switch detail.userVoteStatus { + case .pro: state.myOptionId = options[0].optionId + case .con: state.myOptionId = options[1].optionId + default: break + } + } case let .failure(error): Log.error("[CommentFeature] fetchBattle failed: \(error.localizedDescription)") } @@ -366,6 +483,7 @@ extension CommentFeature { switch result { case let .success(perspective): state.perspectiveId = perspective?.perspectiveId + if let optionId = perspective?.option.optionId { state.myOptionId = optionId } case let .failure(error): Log.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)") } @@ -391,6 +509,11 @@ extension CommentFeature { state.comments = reset ? mapped : state.comments + mapped state.nextCursor = page.nextCursor state.hasNext = page.hasNext + // 표시되는 각 관점의 좋아요 수를 GET /perspectives/{id}/likes 로 조회. + let likeEffects: [Effect] = mapped.compactMap { item in + item.perspectiveId.map { .send(.async(.fetchPerspectiveLikes(perspectiveId: $0))) } + } + return likeEffects.isEmpty ? .none : .merge(likeEffects) case let .failure(error): Log.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)") } @@ -417,6 +540,20 @@ extension CommentFeature { Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") return .none } + + case .mutationFinished: + // 관점 수정/삭제 완료 → 입력 상태 복구 + 목록 갱신 + state.isSubmitting = false + return .send(.async(.fetchPerspectives(reset: true))) + + case let .perspectiveLikesResponse(result): + if case let .success(payload) = result, + let index = state.comments.firstIndex(where: { $0.perspectiveId == payload.perspectiveId }) + { + // GET 은 좋아요 "수" 조회 용도. isLiked 는 목록 응답 값을 신뢰하고 덮어쓰지 않는다. + state.comments[index].likeCount = payload.likeCount + } + return .none } } @@ -432,15 +569,19 @@ extension CommentFeature { return VoteSummary( changeBadgeTitle: fallback.changeBadgeTitle, optionA: VoteOptionSummary( - label: a.label ?? "A", - title: a.title, - representative: a.stance.isEmpty ? fallback.optionA.representative : a.stance, + optionId: a.optionId, + label: a.label ?? fallback.optionA.label, + title: a.title.isEmpty ? fallback.optionA.title : a.title, + representative: fallback.optionA.representative, + imageUrl: a.imageUrl ?? fallback.optionA.imageUrl, percentage: a.ratio ), optionB: VoteOptionSummary( - label: b.label ?? "B", - title: b.title, - representative: b.stance.isEmpty ? fallback.optionB.representative : b.stance, + optionId: b.optionId, + label: b.label ?? fallback.optionB.label, + title: b.title.isEmpty ? fallback.optionB.title : b.title, + representative: fallback.optionB.representative, + imageUrl: b.imageUrl ?? fallback.optionB.imageUrl, percentage: b.ratio ) ) diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index bb2e41b1..4f4bedf4 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -34,6 +34,19 @@ public struct CommentView: View { .padding(.bottom, 16) } .scrollDismissesKeyboard(.interactively) + .simultaneousGesture( + DragGesture(minimumDistance: 24) + .onEnded { value in + // 좌우 스와이프로 필터 탭(전체/옵션A/옵션B) 전환 + guard abs(value.translation.width) > abs(value.translation.height), + abs(value.translation.width) > 50, + let next = adjacentFilter(forward: value.translation.width < 0) + else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + send(.filterTapped(next)) + } + } + ) inputBar() } .background(Color.beige200.ignoresSafeArea()) @@ -51,6 +64,40 @@ public struct CommentView: View { } } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .overlay { + if let comment = store.menuTargetComment { + BottomActionSheet( + items: menuItems(for: comment), + onDismiss: { send(.menuDismissed) } + ) + } + } + .animation(.easeInOut(duration: 0.2), value: store.menuTargetCommentID) + } + + /// "…" 메뉴 항목 — 내 글: 수정/삭제, 남 글: 신고. + private func menuItems(for comment: CommentItem) -> [BottomActionItem] { + if comment.isMine { + return [ + BottomActionItem(title: "수정", systemImage: "pencil") { send(.editTapped(comment.id)) }, + BottomActionItem(title: "삭제", systemImage: "trash", isDestructive: true) { send(.deleteTapped(comment.id)) }, + ] + } else { + return [ + BottomActionItem(title: "신고", systemImage: "exclamationmark.bubble", isDestructive: true) { + send(.reportTapped(comment.id)) + }, + ] + } + } + + /// 현재 선택된 필터 기준 인접 필터(스와이프 방향). 범위를 벗어나면 nil. + private func adjacentFilter(forward: Bool) -> CommentFilter? { + let all = CommentFilter.allCases + guard let idx = all.firstIndex(of: store.selectedFilter) else { return nil } + let nextIdx = forward ? idx + 1 : idx - 1 + guard all.indices.contains(nextIdx) else { return nil } + return all[nextIdx] } } @@ -76,7 +123,12 @@ private extension CommentView { Spacer() - Color.clear.frame(width: 24, height: 24) + Button { send(.forwardTapped) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -99,9 +151,27 @@ private extension CommentView { changeBadge() HStack(alignment: .center, spacing: 12) { - voteSide(store.voteSummary.optionA, alignment: .leading) + HStack(spacing: 4) { + avatarCircle( + imageUrl: store.voteSummary.optionA.imageUrl, + name: store.voteSummary.optionA.representative + ) + Text(percentText(store.voteSummary.optionA.percentage)) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + .fixedSize() + } voteProgress() - voteSide(store.voteSummary.optionB, alignment: .trailing) + HStack(spacing: 4) { + Text(percentText(store.voteSummary.optionB.percentage)) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + .fixedSize() + avatarCircle( + imageUrl: store.voteSummary.optionB.imageUrl, + name: store.voteSummary.optionB.representative + ) + } } } .padding(.horizontal, 16) @@ -111,29 +181,18 @@ private extension CommentView { @ViewBuilder func changeBadge() -> some View { - HStack { + HStack(spacing: 4) { + Image(systemName: "lightbulb") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary500) Text(store.voteSummary.changeBadgeTitle) .pretendardFont(family: .SemiBold, size: 11) .foregroundStyle(.primary500) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) - Spacer() - } - } - - @ViewBuilder - func voteSide( - _ option: VoteOptionSummary, - alignment: HorizontalAlignment - ) -> some View { - VStack(alignment: alignment, spacing: 6) { - avatarLabel(option.representative) - Text(percentText(option.percentage)) - .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral500) } - .frame(width: 52, alignment: alignment == .leading ? .leading : .trailing) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + .frame(maxWidth: .infinity, alignment: .center) } @ViewBuilder @@ -155,22 +214,22 @@ private extension CommentView { } @ViewBuilder - func avatarLabel(_ name: String) -> some View { - VStack(spacing: 4) { - Circle() - .fill(.beige600) - .frame(width: 40, height: 40) - .overlay { + func avatarCircle(imageUrl: String?, name: String) -> some View { + Circle() + .fill(.beige600) + .frame(width: 40, height: 40) + .overlay { + if let imageUrl, let url = URL(string: imageUrl) { + KFImage(url) + .resizable() + .scaledToFill() + } else { Text(String(name.prefix(1))) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.primary500) } - - Text(name) - .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral500) - .lineLimit(1) - } + } + .clipShape(Circle()) } } @@ -221,6 +280,8 @@ private extension CommentView { Text(title) .pretendardFont(family: .Medium, size: 14) .foregroundStyle(isSelected ? .primary500 : .neutral300) + .lineLimit(1) + .minimumScaleFactor(0.7) .frame(maxWidth: .infinity) .padding(.horizontal, 12) .padding(.vertical, 8) @@ -263,15 +324,37 @@ private extension CommentView { private extension CommentView { @ViewBuilder func commentList() -> some View { - VStack(spacing: 12) { - ForEach(store.filteredComments) { comment in - commentCard(comment) + Group { + if store.isLoadingComments, store.comments.isEmpty { + CommentSkeletonView() + } else if store.comments.isEmpty { + emptyState() + } else { + VStack(spacing: 12) { + ForEach(store.comments) { comment in + commentCard(comment) + } + } } } .padding(.horizontal, 16) .padding(.bottom, 24) } + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.neutral300) + Text("아직 등록된 의견이 없어요") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral400) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } + @ViewBuilder func commentCard(_ comment: CommentItem) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift new file mode 100644 index 00000000..4aacc618 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift @@ -0,0 +1,46 @@ +// +// CommentSkeletonView.swift +// Chat +// +// 댓글(관점) 리스트 로딩 placeholder. +// 초기 로드 / 정렬·필터 전환 / 등록 후 갱신 시 노출된다. +// + +import SwiftUI + +import DesignSystem + +struct CommentSkeletonView: View { + var count: Int = 3 + + var body: some View { + VStack(spacing: 12) { + ForEach(0 ..< count, id: \.self) { _ in + card() + } + } + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + SkeletonView(cornerRadius: 14) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(width: 80, height: 12) + SkeletonView(cornerRadius: 4).frame(width: 48, height: 10) + } + Spacer() + } + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(cornerRadius: 4).frame(width: 200, height: 12) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index da7e091e..163a853c 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -18,6 +18,7 @@ import DomainInterface import Entity import LogMacro import UseCase +import DesignSystem @Reducer public struct CommentReplyFeature { @@ -34,6 +35,16 @@ public struct CommentReplyFeature { public var nextCursor: String? public var hasNext: Bool = false public var editingCommentId: Int? + /// "…" 메뉴를 띄울 대상 답글 id. + public var menuTargetReplyId: UUID? + /// 삭제 확인 알럿 대상 commentId. + public var deleteTargetCommentId: Int? + @Presents public var customAlert: CustomAlertState? + + public var menuTargetReply: CommentReplyItem? { + guard let id = menuTargetReplyId else { return nil } + return replies.first { $0.id == id } + } public var isSendEnabled: Bool { !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -53,6 +64,7 @@ public struct CommentReplyFeature { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } @@ -66,6 +78,16 @@ public struct CommentReplyFeature { case beginEditing(commentId: Int, content: String) case cancelEditing case deleteTapped(commentId: Int) + case replyMoreTapped(UUID) + case menuDismissed + case replyEditTapped(UUID) + case replyDeleteTapped(UUID) + case replyReportTapped(UUID) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) } public enum AsyncAction: Equatable { @@ -76,6 +98,7 @@ public struct CommentReplyFeature { case deleteReply(commentId: Int) case toggleParentLike(currentlyLiked: Bool) case toggleReplyLike(commentId: Int, currentlyLiked: Bool) + case fetchParentLikes } public enum InnerAction: Equatable { @@ -86,6 +109,7 @@ public struct CommentReplyFeature { case deleteResponse(Result) case parentLikeResponse(Result) case replyLikeResponse(Result) + case parentLikesResponse(Result) } public enum DelegateAction: Equatable { @@ -124,10 +148,43 @@ public struct CommentReplyFeature { case let .inner(innerAction): return handleInnerAction(state: &state, action: innerAction) + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + case .delegate: return .none } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + state.customAlert = nil + guard let commentId = state.deleteTargetCommentId else { return .none } + state.deleteTargetCommentId = nil + return .send(.async(.deleteReply(commentId: commentId))) + case .cancelTapped: + state.deleteTargetCommentId = nil + state.customAlert = nil + return .none + } + case .dismiss: + state.deleteTargetCommentId = nil + state.customAlert = nil + return .none + } + } } } @@ -142,6 +199,7 @@ extension CommentReplyFeature { case .onAppear: return .merge( .send(.async(.fetchParent)), + .send(.async(.fetchParentLikes)), .send(.async(.fetchReplies(reset: true))) ) @@ -185,6 +243,37 @@ extension CommentReplyFeature { case let .deleteTapped(commentId): return .send(.async(.deleteReply(commentId: commentId))) + + case let .replyMoreTapped(id): + state.menuTargetReplyId = id + return .none + + case .menuDismissed: + state.menuTargetReplyId = nil + return .none + + case let .replyEditTapped(id): + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.editingCommentId = commentId + state.replyText = reply.content + return .none + + case let .replyDeleteTapped(id): + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.deleteTargetCommentId = commentId + state.customAlert = .deleteComment() + return .none + + case let .replyReportTapped(id): + state.menuTargetReplyId = nil + Log.debug("[CommentReplyFeature] report reply tapped: \(id)") + return .none } } } @@ -257,13 +346,14 @@ extension CommentReplyFeature { .cancellable(id: CancelID.mutate, cancelInFlight: false) case let .toggleParentLike(currentlyLiked): - let commentId = state.perspectiveId - return .run { [repository = commentUseCase] send in + // 부모는 관점(perspective) → perspectives/{id}/likes 사용. + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in let result = await Result { if currentlyLiked { - try await repository.unlikeComment(commentId: commentId) + try await repository.unlikePerspective(perspectiveId: perspectiveId) } else { - try await repository.likeComment(commentId: commentId) + try await repository.likePerspective(perspectiveId: perspectiveId) } } .mapError(CommentError.from) @@ -284,6 +374,16 @@ extension CommentReplyFeature { return await send(.inner(.replyLikeResponse(result))) } .cancellable(id: CancelID.like, cancelInFlight: false) + + case .fetchParentLikes: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + .mapError(CommentError.from) + return await send(.inner(.parentLikesResponse(result))) + } } } } @@ -372,6 +472,15 @@ extension CommentReplyFeature { Log.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)") } return .none + + case let .parentLikesResponse(result): + switch result { + case let .success(payload): + state.parentComment.likeCount = payload.likeCount + case let .failure(error): + Log.error("[CommentReplyFeature] fetchParentLikes failed: \(error.localizedDescription)") + } + return .none } } } diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index f33578a9..77acf508 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Kingfisher @ViewAction(for: CommentReplyFeature.self) public struct CommentReplyView: View { @@ -22,11 +23,15 @@ public struct CommentReplyView: View { VStack(spacing: 0) { navigationBar() ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - parentCommentSection() - replySection() + if store.isLoadingReplies, store.replies.isEmpty { + CommentReplySkeletonView() + } else { + VStack(spacing: 0) { + parentCommentSection() + replySection() + } + .padding(.bottom, 16) } - .padding(.bottom, 16) } .scrollDismissesKeyboard(.interactively) inputBar() @@ -39,6 +44,7 @@ public struct CommentReplyView: View { .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } } } @@ -85,8 +91,10 @@ private extension CommentReplyView { VStack(spacing: 0) { commentCard( author: store.parentComment.author, + imageURL: store.parentComment.authorImageURL, timeAgo: store.parentComment.timeAgo, option: store.parentComment.option, + optionLabel: store.parentComment.optionLabel ?? "", content: store.parentComment.content, replyCount: store.parentComment.replyCount, likeCount: store.parentComment.likeCount, @@ -118,14 +126,17 @@ private extension CommentReplyView { ForEach(store.replies) { reply in commentCard( author: reply.author, + imageURL: reply.authorImageURL, timeAgo: reply.timeAgo, option: reply.option, + optionLabel: store.parentComment.optionLabel ?? "", content: reply.content, replyCount: nil, likeCount: reply.likeCount, isLiked: reply.isLiked, background: .beige50, showsReplyCount: false, + moreAction: { send(.replyMoreTapped(reply.id)) }, likeAction: { send(.replyLikeTapped(reply.id)) } ) } @@ -136,18 +147,28 @@ private extension CommentReplyView { func commentCard( author: String, + imageURL: String?, timeAgo: String, option: CommentOption, + optionLabel: String, content: String, replyCount: Int?, likeCount: Int, isLiked: Bool, background: Color, showsReplyCount: Bool, + moreAction: (() -> Void)? = nil, likeAction: @escaping () -> Void ) -> some View { VStack(alignment: .leading, spacing: 8) { - commentHeader(author: author, timeAgo: timeAgo, option: option) + commentHeader( + author: author, + imageURL: imageURL, + timeAgo: timeAgo, + option: option, + optionLabel: optionLabel, + moreAction: moreAction + ) Text(content) .pretendardFont(family: .Regular, size: 13) @@ -185,18 +206,28 @@ private extension CommentReplyView { func commentHeader( author: String, + imageURL: String?, timeAgo: String, - option: CommentOption + option: CommentOption, + optionLabel: String, + moreAction _: (() -> Void)? ) -> some View { HStack(alignment: .top, spacing: 8) { Circle() .fill(.beige600) .frame(width: 36, height: 36) .overlay { - Text(String(author.prefix(1))) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.primary500) + if let imageURL, let url = URL(string: imageURL) { + KFImage(url) + .resizable() + .scaledToFill() + } else { + Text(String(author.prefix(1))) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + } } + .clipShape(Circle()) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -210,7 +241,7 @@ private extension CommentReplyView { .foregroundStyle(.neutral300) } - optionBadge(option) + optionBadge(label: optionLabel, option: option) } Spacer() @@ -222,13 +253,13 @@ private extension CommentReplyView { } } - func optionBadge(_ option: CommentOption) -> some View { - Text(option.label == "A" ? "변기는 변기다" : "예술이다") + func optionBadge(label: String, option: CommentOption) -> some View { + Text(label) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(option == .a ? .primary500 : .beige50) .padding(.horizontal, option == .a ? 4 : 6) .padding(.vertical, 2) - .background(option == .a ? Color.beige600 : Color.primary500, in: RoundedRectangle(cornerRadius: 2)) + .background(option == .a ? .beige600 : .primary500, in: RoundedRectangle(cornerRadius: 2)) } func actionLabel( diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift new file mode 100644 index 00000000..08788065 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift @@ -0,0 +1,73 @@ +// +// CommentReplySkeletonView.swift +// Chat +// +// 대댓글 화면 로딩 placeholder (부모 댓글 + 답글 목록). +// + +import SwiftUI + +import DesignSystem + +struct CommentReplySkeletonView: View { + var replyCount: Int = 3 + + var body: some View { + VStack(spacing: 0) { + parentCard() + .padding(12) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + + VStack(alignment: .leading, spacing: 8) { + SkeletonView(cornerRadius: 4) + .frame(width: 64, height: 14) + .padding(.horizontal, 12) + .padding(.top, 12) + + ForEach(0 ..< replyCount, id: \.self) { _ in + card() + .padding(.horizontal, 12) + } + } + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private func parentCard() -> some View { + card() + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + SkeletonView(cornerRadius: 18) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(width: 90, height: 14) + SkeletonView(cornerRadius: 4).frame(width: 56, height: 16) + } + Spacer() + } + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + HStack { + Spacer() + SkeletonView(cornerRadius: 4).frame(width: 44, height: 14) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index bb629b2d..00e1ba60 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -83,6 +83,10 @@ extension ChatCoordinator { } return .none + case let .routeAction(_, action: .preVote(.delegate(.alreadyFinalVoted(battleId)))): + state.routes.push(.comment(.init(battleId: battleId))) + return .none + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): return .send(.view(.backAction)) diff --git a/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift b/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift new file mode 100644 index 00000000..8901d7b1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift @@ -0,0 +1,73 @@ +// +// BottomActionSheet.swift +// DesignSystem +// +// 아이콘 + 라벨 행을 가진 하단 액션 시트. +// 댓글 "…" 메뉴(수정/삭제/신고)처럼 이미지가 필요한 메뉴에 사용한다. +// + +import SwiftUI + +public struct BottomActionItem: Identifiable { + public let id = UUID() + public let title: String + public let systemImage: String + public let isDestructive: Bool + public let action: () -> Void + + public init( + title: String, + systemImage: String, + isDestructive: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.systemImage = systemImage + self.isDestructive = isDestructive + self.action = action + } +} + +public struct BottomActionSheet: View { + private let items: [BottomActionItem] + private let onDismiss: () -> Void + + public init( + items: [BottomActionItem], + onDismiss: @escaping () -> Void + ) { + self.items = items + self.onDismiss = onDismiss + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { onDismiss() } + + VStack(spacing: 8) { + ForEach(items) { item in + Button { + onDismiss() + item.action() + } label: { + HStack(spacing: 4) { + Image(systemName: item.systemImage) + .font(.system(size: 14, weight: .medium)) + Text(item.title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 28) + } + .transition(.opacity) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 2a8a761a..76f4c6c6 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -81,6 +81,26 @@ public extension CustomAlertState where Action == CustomAlertAction { ) } + static func deletePerspective() -> CustomAlertState { + CustomAlertState( + title: "관점을 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .confirmation + ) + } + + static func deleteComment() -> CustomAlertState { + CustomAlertState( + title: "댓글을 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .confirmation + ) + } + static func report() -> CustomAlertState { CustomAlertState( title: "", From b4b19fcb813165cf314792f855911dfc71cc6efc Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:19:54 +0900 Subject: [PATCH 28/42] Build pre-vote share content from card snapshots Move share sheet models out of PreVoteFeature and build richer share payloads from battle text, tags, options, URL, and a rendered card snapshot. Rejected: Keep ShareItem nested in PreVoteFeature | it violates the reducer-only feature file rule and bloats the reducer surface. #4 --- .../Sources/Home/Battle/BattleDetail.swift | 1 + .../Sources/Vote/Model/ShareContent.swift | 48 ++++++++++ .../Chat/Sources/Vote/Model/ShareItem.swift | 25 +++++ .../Sources/Vote/Reducer/PreVoteFeature.swift | 96 ++++++++++++------- .../Chat/Sources/Vote/View/PreVoteView.swift | 29 +++++- 5 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift create mode 100644 Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift index f5c26c71..bd45191f 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift @@ -123,6 +123,7 @@ public enum BattleStep: String, Equatable, Hashable, CaseIterable { case listening = "LISTENING" case postVote = "POST_VOTE" case finished = "FINISHED" + case completed = "COMPLETED" case unknown public init(rawValue: String) { diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift new file mode 100644 index 00000000..d63224b4 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift @@ -0,0 +1,48 @@ +// +// ShareContent.swift +// Chat +// +// 공유 시트에 실어 보낼 컨텐츠 묶음. 카드 스냅샷(우선) + 썸네일(fallback) + 본문 텍스트 + URL. +// + +import Foundation + +public struct ShareContent: Equatable { + public let title: String + public let summary: String + public let hashtags: [String] + public let optionLine: String? + public let url: String + public let thumbnailURL: String? + public let snapshotData: Data? + + public init( + title: String, + summary: String, + hashtags: [String], + optionLine: String?, + url: String, + thumbnailURL: String?, + snapshotData: Data? + ) { + self.title = title + self.summary = summary + self.hashtags = hashtags + self.optionLine = optionLine + self.url = url + self.thumbnailURL = thumbnailURL + self.snapshotData = snapshotData + } + + /// SNS / 메시지 공유 시 동행되는 본문 텍스트. + /// 빈 섹션은 자동으로 건너뛴다. + public var displayText: String { + var lines: [String] = [] + if !title.isEmpty { lines.append(title) } + if !summary.isEmpty { lines.append(summary) } + if let optionLine, !optionLine.isEmpty { lines.append(optionLine) } + let joinedHashtags = hashtags.joined(separator: " ") + if !joinedHashtags.isEmpty { lines.append(joinedHashtags) } + return lines.joined(separator: "\n\n") + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift new file mode 100644 index 00000000..c2b951fb --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift @@ -0,0 +1,25 @@ +// +// ShareItem.swift +// Chat +// +// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. +// + +import Foundation + +public struct ShareItem: Equatable, Identifiable { + public let id: UUID + public let items: [Any] + + public init( + id: UUID = UUID(), + items: [Any] + ) { + self.id = id + self.items = items + } + + public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index e3cf20b1..337a731f 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -57,24 +57,6 @@ public struct PreVoteFeature { } } - /// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩. - public struct ShareItem: Equatable, Identifiable { - public let id: UUID - public let items: [Any] - - public init( - id: UUID = UUID(), - items: [Any] - ) { - self.id = id - self.items = items - } - - public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { - lhs.id == rhs.id - } - } - public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) @@ -88,7 +70,7 @@ public struct PreVoteFeature { public enum View { case onAppear case backButtonTapped - case shareTapped + case shareTapped(snapshot: Data?) case optionTapped(optionId: Int) case primaryButtonTapped } @@ -97,7 +79,7 @@ public struct PreVoteFeature { case fetchBattleDetail case fetchMyPerspective case deleteMyPerspective(perspectiveId: Int) - case prepareShare(title: String, url: String, imageURL: String?) + case prepareShare(ShareContent) case submitPreVote(battleId: Int, optionId: Int) case submitPostVote(battleId: Int, optionId: Int) } @@ -123,6 +105,8 @@ public struct PreVoteFeature { public enum DelegateAction: Equatable { case dismiss case voteSubmitted(battleId: Int, voteMode: VoteMode, result: PreVoteResult) + /// 이미 최종 투표(POST_VOTE)까지 마친 상태 — 댓글 화면으로 바로 이동. + case alreadyFinalVoted(battleId: Int) } nonisolated enum CancelID: Hashable { @@ -176,7 +160,9 @@ extension PreVoteFeature { if state.battleDetail == nil, state.battle == nil, !state.isLoading { effects.append(.send(.async(.fetchBattleDetail))) } - if state.voteMode == .pre, state.myPerspective == nil { + // pre/post 모두 진입 시 내 참여(perspective) 여부를 조회한다. + // 이미 참여했으면 "다시 투표" 알럿 → 삭제 후 재투표. + if state.myPerspective == nil { effects.append(.send(.async(.fetchMyPerspective))) } return effects.isEmpty ? .none : .merge(effects) @@ -184,12 +170,39 @@ extension PreVoteFeature { case .backButtonTapped: return .send(.delegate(.dismiss)) - case .shareTapped: - let title = state.battleDetail?.battleInfo.title ?? state.battle?.titleLine1 ?? "" - let url = state.battleDetail?.shareUrl - ?? "https://picke.store/battles/\(state.battleId)" - let imageURL = state.battleDetail?.battleInfo.thumbnailUrl ?? state.battle?.backgroundImageURL - return .send(.async(.prepareShare(title: title, url: url, imageURL: imageURL))) + case let .shareTapped(snapshot): + let detail = state.battleDetail + let battle = state.battle + let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" + let url = detail?.shareUrl ?? "https://picke.store/battles/\(state.battleId)" + let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL + let summary = { + if let description = detail?.description, !description.isEmpty { return description } + if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } + return battle?.summary ?? "" + }() + let hashtags: [String] = { + if let tags = detail?.categoryTags, !tags.isEmpty { + return tags.map { "#\($0.name)" } + } + return battle?.tags ?? [] + }() + let optionLine: String? = { + guard let left = battle?.leftOption.stance, + let right = battle?.rightOption.stance + else { return nil } + return "🆚 A: \(left) vs B: \(right)" + }() + let content = ShareContent( + title: title, + summary: summary, + hashtags: hashtags, + optionLine: optionLine, + url: url, + thumbnailURL: thumbnailURL, + snapshotData: snapshot + ) + return .send(.async(.prepareShare(content))) case let .optionTapped(optionId): state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId @@ -197,11 +210,12 @@ extension PreVoteFeature { case .primaryButtonTapped: guard let optionId = state.selectedOptionId else { return .none } - state.isSubmitting = true switch state.voteMode { case .pre: + state.isSubmitting = true return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) case .post: + state.isSubmitting = true return .send(.async(.submitPostVote(battleId: state.battleId, optionId: optionId))) } } @@ -246,14 +260,22 @@ extension PreVoteFeature { } .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) - case let .prepareShare(title, url, imageURL): + case let .prepareShare(content): return .run { send in - var items: [Any] = [title, url] + var items: [Any] = [content.displayText] - if let imageURL, - let remoteURL = URL(string: imageURL), - let (data, _) = try? await URLSession.shared.data(from: remoteURL), - let image = UIImage(data: data) + if let url = URL(string: content.url) { + items.append(url) + } else { + items.append(content.url) + } + + if let data = content.snapshotData, let image = UIImage(data: data) { + items.append(image) + } else if let thumbnailURL = content.thumbnailURL, + let remoteURL = URL(string: thumbnailURL), + let (data, _) = try? await URLSession.shared.data(from: remoteURL), + let image = UIImage(data: data) { items.append(image) } @@ -340,8 +362,10 @@ extension PreVoteFeature { case let .success(voteResult): return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) case let .failure(error): + // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. + // 재투표가 불가하므로 결과(댓글) 화면으로 이동한다. Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)") - return .none + return .send(.delegate(.alreadyFinalVoted(battleId: state.battleId))) } } } @@ -382,7 +406,7 @@ extension PreVoteFeature { action: DelegateAction ) -> Effect { switch action { - case .dismiss, .voteSubmitted: + case .dismiss, .voteSubmitted, .alreadyFinalVoted: return .none } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index de0e14cf..f8092235 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -139,7 +139,9 @@ extension PreVoteView { Spacer() - Button { send(.shareTapped) } label: { + Button { + send(.shareTapped(snapshot: captureCardSnapshot())) + } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 24, weight: .regular)) .frame(width: 24, height: 24) @@ -322,6 +324,31 @@ extension PreVoteView { } } +// MARK: - Share snapshot + +extension PreVoteView { + private static let snapshotWidth: CGFloat = 360 + + @MainActor + private func captureCardSnapshot() -> Data? { + guard let battle = store.battle else { return nil } + let renderer = ImageRenderer(content: shareSnapshotCard(battle)) + renderer.scale = UIScreen.main.scale + return renderer.uiImage?.pngData() + } + + @ViewBuilder + private func shareSnapshotCard(_ battle: PreVoteBattle) -> some View { + VStack(spacing: Self.contentToOptionSpacing) { + contentSection(battle) + optionSection(battle) + } + .padding(16) + .frame(width: Self.snapshotWidth) + .background(Color.beige50) + } +} + #Preview { PreVoteView( store: Store(initialState: PreVoteFeature.State()) { From bbb1c504c773530e89e832adc470e7c1d9371a81 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:20:04 +0900 Subject: [PATCH 29/42] Allow quiz card answer toggling Let users deselect or change the selected quiz choice instead of locking the first tapped answer. Rejected: Keep buttons disabled after first tap | it prevents correcting accidental taps before any commit action. #4 --- .../Home/Sources/Main/View/Components/QuizCardView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift index 5f237b66..667beb1e 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift @@ -81,7 +81,7 @@ struct QuizCardView: View { let isSelected = selectedOption == choice let hasAnswered = selectedOption != nil Button { - if selectedOption == nil { selectedOption = choice } + selectedOption = isSelected ? nil : choice } label: { VStack(spacing: 2) { resultBadge(isSelected: isSelected, isCorrect: isCorrect) @@ -101,7 +101,6 @@ struct QuizCardView: View { .opacity(hasAnswered && !isSelected ? 0.5 : 1) } .buttonStyle(.plain) - .disabled(hasAnswered) } @ViewBuilder From 1ab0822837548d8ca9a6d08a4cbad70ade06dadb Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:20:19 +0900 Subject: [PATCH 30/42] Document reducer-only feature file guidance Add project guidance that keeps TCA feature files limited to reducer components and moves presentation helper models into module Model files. Rejected: Leave model extraction as an implicit convention | future reducers could reintroduce nested presentation types. #4 --- AGENTS.md | 47 ++++++++++++++++++++++++++++++++++++++ ONBOARDING.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 ONBOARDING.md diff --git a/AGENTS.md b/AGENTS.md index 2f645270..4bcfbdd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -451,6 +451,53 @@ public struct VoteSummary: Equatable { ... } // ← Entity 로 레퍼런스: `Entity/Sources/Home/Comment.swift` (CommentItem, CommentFilter, CommentSort, CommentReplyItem, VoteSummary) +#### 🗂️ Feature 파일 — Reducer 관련 코드만 (필수) + +`XxxFeature.swift` 안에는 **TCA Reducer 구성 요소만** 둔다. presentation 전용 helper 라도 모델/타입은 nested 로 두지 말고 **같은 모듈의 `Model/` 폴더에 별도 파일**로 분리한다. + +```swift +// ✅ Feature 파일은 reducer 구성 요소만 +@Reducer +public struct PreVoteFeature { + @ObservableState + public struct State: Equatable { + public var shareItem: ShareItem? // ← top-level 타입 참조 + // … + } + + public enum Action: ViewAction, BindableAction { … } + public enum View { case shareTapped(snapshot: Data?) … } + public enum AsyncAction: Equatable { case prepareShare(ShareContent) … } + public enum InnerAction: Equatable { case sharePrepared(ShareItem) … } + nonisolated enum CancelID: Hashable { … } + + @Dependency(\.battleUseCase) private var battleUseCase + public var body: some Reducer { … } +} + +// Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift +public struct ShareItem: Equatable, Identifiable { … } + +// Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift +public struct ShareContent: Equatable { … } + +// ❌ 금지 — Feature 안에 nested 로 같이 선언 +@Reducer +public struct PreVoteFeature { + public struct ShareItem: Equatable, Identifiable { … } // ← Model/ 로 분리 + public struct ShareContent: Equatable { … } // ← Model/ 로 분리 +} +``` + +규칙: +- Feature 파일 (`XxxFeature.swift`) 에는 **`State` / `Action` / `View` / `AsyncAction` / `InnerAction` / `ScopeAction` / `DelegateAction` / `CancelID` / `body` / `@Dependency` 만** 둔다 +- presentation 전용 모델 (`ShareItem`, `ShareContent`, sheet item 등) 은 **같은 모듈의 `Model/` 폴더에 top-level public struct/enum** 으로 분리 +- 도메인 모델은 [위 규칙](#-feature-화면-모델--item--filter--sort-는-entity-모듈에-정의-필수) 대로 `Domain/Entity` 모듈로 +- presentation 모델에 포함되는 헬퍼 (텍스트 빌드 같은 표현 로직) 는 모델 파일 안에 computed property / method 로 함께 둔다 — reducer 가 책임지지 않는다 +- Reducer 안의 helper 함수 (`makeBattle(from:)`, `handleViewAction(state:action:)` 등) 는 Feature 파일에 그대로 둬도 OK — reducer 관련이라는 점이 명확하면 분리 강제하지 않음 +- 파일 위치: `Projects/Presentation/<모듈>/Sources/<도메인>/Model/.swift` +- 레퍼런스: `Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift`, `Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift` + #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 `do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 00000000..44a205fd --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,63 @@ +# Welcome to Picke iOS + +## How We Use Claude + +Based on Roy's usage over the last 30 days: + +Work Type Breakdown: + Improve Quality ████████░░░░░░░░░░░░ 38% + Build Feature ██████░░░░░░░░░░░░░░ 31% + Debug Fix ███░░░░░░░░░░░░░░░░░ 15% + Plan Design ██░░░░░░░░░░░░░░░░░░ 8% + Write Docs ██░░░░░░░░░░░░░░░░░░ 8% + +Top Skills & Commands: + /plugin ████████████████████ 3x/month + /figma:figma-use ███████░░░░░░░░░░░░░ 1x/month + +Top MCP Servers: + plugin_figma_figma ████████████████████ 15 calls + +## Your Setup Checklist + +### Codebases +- [ ] picke-ios — https://github.com/swyp-find/picke-ios (메인 iOS 앱, Tuist + TCA + Clean Architecture) +- [ ] design-tokens — https://github.com/SWYP-Find/design-tokens (Tokens Studio JSON, 자동 코드젠 소스) +- [ ] Attendance_iOS — 사내 다른 iOS 레퍼런스 (UseCase / AGENTS.md 패턴 참고용) + +### MCP Servers to Activate +- [ ] plugin_figma_figma — Figma 디자인 파일을 Claude 안에서 직접 조회/조작 (변수 / 컴포넌트 / 스냅샷). Claude Code 플러그인 마켓에서 `figma@claude-plugins-official` 설치 후 워크스페이스 OAuth 연결 + +### Skills to Know About +- /plugin — Claude Code 플러그인 설치/관리. Figma 등 MCP 추가 시 진입점 +- /figma:figma-use — Figma MCP 의 `use_figma` 툴을 안전하게 호출하기 위한 prerequisite skill. 디자인 파일에서 변수/컴포넌트 만들거나 노드 조작 전 반드시 먼저 실행 + +## Team Tips + +_TODO_ + +## Get Started + +_TODO_ + + From 3f1fe0c5045ebd8623f24e608476e60354895b7f Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 3 Jun 2026 23:25:26 +0900 Subject: [PATCH 31/42] Mark my comments in comment screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a compact 나 badge next to the author name for comments and replies backed by isMine state. Rejected: Replace the author name with 나 | preserving the nickname keeps the row identity clear while adding ownership context. #4 --- .../Sources/Comment/View/CommentView.swift | 14 ++++++++++++++ .../CommentReply/View/CommentReplyView.swift | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 4f4bedf4..cd25b488 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -404,6 +404,10 @@ private extension CommentView { .foregroundStyle(.neutral500) .lineLimit(1) + if comment.isMine { + myBadge() + } + Text(comment.timeAgo) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) @@ -413,6 +417,16 @@ private extension CommentView { } } + @ViewBuilder + func myBadge() -> some View { + Text("나") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.beige50) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + @ViewBuilder func reportButton(commentId: UUID) -> some View { Button { send(.reportButtonTapped(commentId)) } label: { diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 77acf508..11e66524 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -99,6 +99,7 @@ private extension CommentReplyView { replyCount: store.parentComment.replyCount, likeCount: store.parentComment.likeCount, isLiked: store.parentComment.isLiked, + isMine: store.parentComment.isMine, background: .beige50, showsReplyCount: true, likeAction: { send(.parentLikeTapped) } @@ -134,6 +135,7 @@ private extension CommentReplyView { replyCount: nil, likeCount: reply.likeCount, isLiked: reply.isLiked, + isMine: reply.isMine, background: .beige50, showsReplyCount: false, moreAction: { send(.replyMoreTapped(reply.id)) }, @@ -155,6 +157,7 @@ private extension CommentReplyView { replyCount: Int?, likeCount: Int, isLiked: Bool, + isMine: Bool, background: Color, showsReplyCount: Bool, moreAction: (() -> Void)? = nil, @@ -167,6 +170,7 @@ private extension CommentReplyView { timeAgo: timeAgo, option: option, optionLabel: optionLabel, + isMine: isMine, moreAction: moreAction ) @@ -210,6 +214,7 @@ private extension CommentReplyView { timeAgo: String, option: CommentOption, optionLabel: String, + isMine: Bool, moreAction _: (() -> Void)? ) -> some View { HStack(alignment: .top, spacing: 8) { @@ -236,6 +241,10 @@ private extension CommentReplyView { .foregroundStyle(.neutral500) .lineLimit(1) + if isMine { + myBadge() + } + Text(timeAgo) .pretendardFont(family: .SemiBold, size: 10) .foregroundStyle(.neutral300) @@ -253,6 +262,16 @@ private extension CommentReplyView { } } + @ViewBuilder + func myBadge() -> some View { + Text("나") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.beige50) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + func optionBadge(label: String, option: CommentOption) -> some View { Text(label) .pretendardFont(family: .Medium, size: 12) From 4834ea1ebb3e222e6201dd86ed0c7ba71104df5d Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 00:43:42 +0900 Subject: [PATCH 32/42] Blend pre-vote image into content background The pre-vote hero image needs to fade into the beige content area so the text-image boundary does not read as a hard middle line. Rejected: Move content lower below the image | it changes the composition and can leave the boundary visible. #4 --- .../Chat/Sources/Vote/View/PreVoteView.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index f8092235..b1efbe71 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -75,6 +75,9 @@ public struct PreVoteView: View { Color.clear .frame(height: Self.contentOverlapTopOffset) + Spacer() + .frame(height: 100) + contentArea(battle) } .frame(width: proxy.size.width) @@ -89,6 +92,8 @@ public struct PreVoteView: View { } private static let contentOverlapTopOffset: CGFloat = 280 + private static let backgroundImageHeight: CGFloat = 512 + private static let imageToContentGradientHeight: CGFloat = 260 private static let rootContentSpacing: CGFloat = 40 private static let contentToOptionSpacing: CGFloat = 32 private static let optionCardHeight: CGFloat = 106 @@ -116,11 +121,30 @@ extension PreVoteView { } Color.black.opacity(0.4) + + VStack { + Spacer() + imageToContentGradient() + } } .frame(maxWidth: .infinity) - .frame(height: 512) + .frame(height: Self.backgroundImageHeight) .clipped() } + + @ViewBuilder + private func imageToContentGradient() -> some View { + LinearGradient( + stops: [ + .init(color: Color.beige50.opacity(0), location: 0), + .init(color: Color.beige50.opacity(0.55), location: 0.55), + .init(color: .beige50, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: Self.imageToContentGradientHeight) + } } // MARK: - Navigation bar @@ -171,7 +195,8 @@ extension PreVoteView { LinearGradient( stops: [ .init(color: Color.beige50.opacity(0), location: 0), - .init(color: .beige50, location: 0.35), + .init(color: Color.beige50.opacity(0.72), location: 0.42), + .init(color: .beige50, location: 0.7), .init(color: .beige50, location: 1), ], startPoint: .top, From e9e7f0621de0dcba91851e579bd771b4c05e7895 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 00:51:01 +0900 Subject: [PATCH 33/42] Keep pre-vote layout fixed while isolating layout tokens PreVote needs a fixed presentation without user scrolling, and its screen-specific dimensions should live outside the view body for easier adjustment. Rejected: Keep constants as private static values in PreVoteView | it keeps the view noisy and ignores the requested file split. #4 --- .../Sources/Vote/View/PreVoteLayout.swift | 25 ++++++++++ .../Chat/Sources/Vote/View/PreVoteView.swift | 49 +++++++------------ 2 files changed, 44 insertions(+), 30 deletions(-) create mode 100644 Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift new file mode 100644 index 00000000..bf57726c --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift @@ -0,0 +1,25 @@ +// +// PreVoteLayout.swift +// Chat +// +// PreVote 화면 레이아웃 토큰. +// + +import CoreGraphics + +enum PreVoteLayout { + static let contentOverlapTopOffset: CGFloat = 280 + static let contentGradientSpacerHeight: CGFloat = 100 + static let backgroundImageHeight: CGFloat = 512 + static let imageToContentGradientHeight: CGFloat = 260 + static let rootContentSpacing: CGFloat = 40 + static let contentToOptionSpacing: CGFloat = 32 + static let optionCardHeight: CGFloat = 106 + static let contentHorizontalPadding: CGFloat = 16 + static let contentTopPadding: CGFloat = 80 + static let ctaHeight: CGFloat = 52 + static let ctaBottomSpacing: CGFloat = 40 + static let ctaHorizontalPadding: CGFloat = 16 + static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing + static let snapshotWidth: CGFloat = 360 +} diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index b1efbe71..8d29d126 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -35,8 +35,8 @@ public struct PreVoteView: View { .overlay(alignment: .bottom) { if !shouldShowSkeleton { primaryButton() - .padding(.horizontal, Self.ctaHorizontalPadding) - .padding(.bottom, Self.ctaBottomSpacing) + .padding(.horizontal, PreVoteLayout.ctaHorizontalPadding) + .padding(.bottom, PreVoteLayout.ctaBottomSpacing) } } .overlay(alignment: .top) { @@ -73,16 +73,17 @@ public struct PreVoteView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { Color.clear - .frame(height: Self.contentOverlapTopOffset) + .frame(height: PreVoteLayout.contentOverlapTopOffset) Spacer() - .frame(height: 100) + .frame(height: PreVoteLayout.contentGradientSpacerHeight) contentArea(battle) } .frame(width: proxy.size.width) } .scrollBounceBehavior(.basedOnSize) + .scrollDisabled(true) } .ignoresSafeArea(edges: .top) } @@ -91,16 +92,6 @@ public struct PreVoteView: View { } } - private static let contentOverlapTopOffset: CGFloat = 280 - private static let backgroundImageHeight: CGFloat = 512 - private static let imageToContentGradientHeight: CGFloat = 260 - private static let rootContentSpacing: CGFloat = 40 - private static let contentToOptionSpacing: CGFloat = 32 - private static let optionCardHeight: CGFloat = 106 - private static let ctaHeight: CGFloat = 52 - private static let ctaBottomSpacing: CGFloat = 40 - private static let ctaHorizontalPadding: CGFloat = 16 - private static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing } // MARK: - Background @@ -128,7 +119,7 @@ extension PreVoteView { } } .frame(maxWidth: .infinity) - .frame(height: Self.backgroundImageHeight) + .frame(height: PreVoteLayout.backgroundImageHeight) .clipped() } @@ -136,14 +127,14 @@ extension PreVoteView { private func imageToContentGradient() -> some View { LinearGradient( stops: [ - .init(color: Color.beige50.opacity(0), location: 0), - .init(color: Color.beige50.opacity(0.55), location: 0.55), + .init(color: .beige50.opacity(0), location: 0), + .init(color: .beige50.opacity(0.55), location: 0.55), .init(color: .beige50, location: 1), ], startPoint: .top, endPoint: .bottom ) - .frame(height: Self.imageToContentGradientHeight) + .frame(height: PreVoteLayout.imageToContentGradientHeight) } } @@ -183,19 +174,19 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private func contentArea(_ battle: PreVoteBattle) -> some View { - VStack(spacing: Self.contentToOptionSpacing) { + VStack(spacing: PreVoteLayout.contentToOptionSpacing) { contentSection(battle) optionSection(battle) } - .padding(.horizontal, 16) - .padding(.top, 80) - .padding(.bottom, Self.contentBottomSpacing) + .padding(.horizontal, PreVoteLayout.contentHorizontalPadding) + .padding(.top, PreVoteLayout.contentTopPadding) + .padding(.bottom, PreVoteLayout.contentBottomSpacing) .frame(maxWidth: .infinity) .background( LinearGradient( stops: [ - .init(color: Color.beige50.opacity(0), location: 0), - .init(color: Color.beige50.opacity(0.72), location: 0.42), + .init(color: .beige50.opacity(0), location: 0), + .init(color: .beige50.opacity(0.72), location: 0.42), .init(color: .beige50, location: 0.7), .init(color: .beige50, location: 1), ], @@ -300,7 +291,7 @@ extension PreVoteView { } .padding(8) .frame(maxWidth: .infinity) - .frame(height: Self.optionCardHeight) + .frame(height: PreVoteLayout.optionCardHeight) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) @@ -343,7 +334,7 @@ extension PreVoteView { CustomButton( action: { send(.primaryButtonTapped) }, title: store.primaryButtonTitle, - config: CustomButtonConfig.primary(.large, height: Self.ctaHeight), + config: CustomButtonConfig.primary(.large, height: PreVoteLayout.ctaHeight), isEnable: store.isPrimaryButtonEnabled ) } @@ -352,8 +343,6 @@ extension PreVoteView { // MARK: - Share snapshot extension PreVoteView { - private static let snapshotWidth: CGFloat = 360 - @MainActor private func captureCardSnapshot() -> Data? { guard let battle = store.battle else { return nil } @@ -364,12 +353,12 @@ extension PreVoteView { @ViewBuilder private func shareSnapshotCard(_ battle: PreVoteBattle) -> some View { - VStack(spacing: Self.contentToOptionSpacing) { + VStack(spacing: PreVoteLayout.contentToOptionSpacing) { contentSection(battle) optionSection(battle) } .padding(16) - .frame(width: Self.snapshotWidth) + .frame(width: PreVoteLayout.snapshotWidth) .background(Color.beige50) } } From 75d9919ad4f922dd4d91e1049ccea94726078221 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 00:57:32 +0900 Subject: [PATCH 34/42] Center and fill comment avatar images Comment and reply avatars should crop consistently inside circular frames so remote images do not appear offset or underfilled. Rejected: Patch only CommentView | CommentReplyView renders the same avatar pattern and would keep the inconsistency. #4 --- .../Sources/Comment/View/CommentView.swift | 44 ++++---------- .../View/Components/CommentAvatarView.swift | 60 +++++++++++++++++++ .../CommentReply/View/CommentReplyView.swift | 21 ++----- 3 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index cd25b488..4c2d5da3 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -10,7 +10,6 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity -import Kingfisher @ViewAction(for: CommentFeature.self) public struct CommentView: View { @@ -215,21 +214,11 @@ private extension CommentView { @ViewBuilder func avatarCircle(imageUrl: String?, name: String) -> some View { - Circle() - .fill(.beige600) - .frame(width: 40, height: 40) - .overlay { - if let imageUrl, let url = URL(string: imageUrl) { - KFImage(url) - .resizable() - .scaledToFill() - } else { - Text(String(name.prefix(1))) - .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.primary500) - } - } - .clipShape(Circle()) + CommentAvatarView( + imageURL: imageUrl, + fallback: name, + size: 40 + ) } } @@ -509,24 +498,11 @@ private extension CommentView { urlString: String?, fallback: String ) -> some View { - if let urlString, let url = URL(string: urlString) { - KFImage(url) - .placeholder { Color.beige600 } - .resizable() - .scaledToFill() - .frame(width: 36, height: 36) - .clipShape(Circle()) - .overlay(Circle().stroke(.beige600, lineWidth: 1)) - } else { - Circle() - .fill(.beige600) - .frame(width: 36, height: 36) - .overlay { - Text(String(fallback.prefix(1))) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.primary500) - } - } + CommentAvatarView( + imageURL: urlString, + fallback: fallback, + size: 36 + ) } } diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift new file mode 100644 index 00000000..d75f3042 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift @@ -0,0 +1,60 @@ +// +// CommentAvatarView.swift +// Chat +// +// 댓글 프로필 이미지 공통 뷰. +// + +import SwiftUI + +import DesignSystem +import Kingfisher + +struct CommentAvatarView: View { + let imageURL: String? + let fallback: String + let size: CGFloat + let imageScale: CGFloat + + init( + imageURL: String?, + fallback: String, + size: CGFloat, + imageScale: CGFloat = 1.16 + ) { + self.imageURL = imageURL + self.fallback = fallback + self.size = size + self.imageScale = imageScale + } + + var body: some View { + ZStack { + Circle() + .fill(.beige600) + + avatarContent() + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(.beige600, lineWidth: 1)) + } +} + +private extension CommentAvatarView { + @ViewBuilder + func avatarContent() -> some View { + if let imageURL, let url = URL(string: imageURL) { + KFImage(url) + .placeholder { Color.beige600 } + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .scaleEffect(imageScale) + } else { + Text(String(fallback.prefix(1))) + .pretendardFont(family: .SemiBold, size: size <= 36 ? 13 : 14) + .foregroundStyle(.primary500) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 11e66524..3658aa00 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -8,7 +8,6 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity -import Kingfisher @ViewAction(for: CommentReplyFeature.self) public struct CommentReplyView: View { @@ -218,21 +217,11 @@ private extension CommentReplyView { moreAction _: (() -> Void)? ) -> some View { HStack(alignment: .top, spacing: 8) { - Circle() - .fill(.beige600) - .frame(width: 36, height: 36) - .overlay { - if let imageURL, let url = URL(string: imageURL) { - KFImage(url) - .resizable() - .scaledToFill() - } else { - Text(String(author.prefix(1))) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.primary500) - } - } - .clipShape(Circle()) + CommentAvatarView( + imageURL: imageURL, + fallback: author, + size: 36 + ) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { From 885b026f3dc9adecf07c0574d2ffcbb80a582dbe Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:49:37 +0900 Subject: [PATCH 35/42] =?UTF-8?q?fix:=20=EB=8C=80=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20PUT=20=E2=86=92=20PATCH=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=88=98=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Perspective/PerspectiveService.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift index 740e2e01..97dcd5e0 100644 --- a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift +++ b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift @@ -26,6 +26,8 @@ public enum PerspectiveService { case likePerspective(perspectiveId: Int) case unlikePerspective(perspectiveId: Int) case fetchPerspectiveLikes(perspectiveId: Int) + case reportPerspective(perspectiveId: Int) + case reportComment(perspectiveId: Int, commentId: Int) } extension PerspectiveService: BaseTargetType { @@ -55,6 +57,10 @@ extension PerspectiveService: BaseTargetType { return PerspectiveAPI.likes(perspectiveId: perspectiveId).description case let .fetchPerspectiveLikes(perspectiveId): return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .reportPerspective(perspectiveId): + return PerspectiveAPI.reports(perspectiveId: perspectiveId).description + case let .reportComment(perspectiveId, commentId): + return PerspectiveAPI.reportComment(perspectiveId: perspectiveId, commentId: commentId).description } } @@ -64,11 +70,9 @@ extension PerspectiveService: BaseTargetType { switch self { case .detail, .listLabeledComments, .fetchPerspectiveLikes: return .get - case .createComment, .likePerspective: + case .createComment, .likePerspective, .reportPerspective, .reportComment: return .post - case .updateComment: - return .put - case .updatePerspective: + case .updateComment, .updatePerspective: return .patch case .deleteComment, .deletePerspective, .unlikePerspective: return .delete @@ -96,6 +100,8 @@ extension PerspectiveService: BaseTargetType { return nil case .likePerspective, .unlikePerspective, .fetchPerspectiveLikes: return nil + case .reportPerspective, .reportComment: + return nil } } From dbb0a35720feb3b26aa92e78a97cac6fd081e103 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:50:29 +0900 Subject: [PATCH 36/42] =?UTF-8?q?feat:=20=ED=9D=A5=EB=AF=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B6=94=EC=B2=9C=20=EB=B0=B0=ED=8B=80=20API=20+?= =?UTF-8?q?=20=EA=B4=80=EC=A0=90=20=EB=93=B1=EB=A1=9D=20=EC=A7=84=EC=98=81?= =?UTF-8?q?=20optionId=20=EC=B6=94=EA=B0=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/RecommendedBattleDataDTO.swift | 38 ++++++++ .../Mapper/RecommendedBattleDataDTO+.swift | 50 ++++++++++ .../Sources/Battle/BattleRepositoryImpl.swift | 16 +++- .../Sources/Battle/BattleService.swift | 9 +- .../Battle/CreatePerspectiveRequest.swift | 20 ++-- .../Sources/Battle/BattleInterface.swift | 3 +- .../Battle/DefaultBattleRepositoryImpl.swift | 8 +- .../Home/Battle/RecommendedBattle.swift | 94 +++++++++++++++++++ .../Sources/Battle/BattleUseCase.swift | 6 +- 10 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 522a3859..849ddae3 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -13,6 +13,7 @@ public enum BattleAPI { case voteStats(battleId: Int) case perspectives(battleId: Int) case myPerspective(battleId: Int) + case recommendations(battleId: Int) public var description: String { switch self { @@ -30,6 +31,8 @@ public enum BattleAPI { return "\(battleId)/perspectives" case let .myPerspective(battleId): return "\(battleId)/perspectives/me" + case let .recommendations(battleId): + return "\(battleId)/recommendations/interesting" } } } diff --git a/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift new file mode 100644 index 00000000..3a7760ed --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift @@ -0,0 +1,38 @@ +// +// RecommendedBattleDataDTO.swift +// Model +// + +import Foundation + +public struct RecommendedBattlePageDataDTO: Decodable { + public let items: [RecommendedBattleDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct RecommendedBattleDTO: Decodable { + public let battleId: Int + public let title: String? + public let summary: String? + public let audioDuration: Int? + public let viewCount: Int? + public let tags: [RecommendedBattleTagDTO]? + public let participantsCount: Int? + public let options: [RecommendedBattleOptionDTO]? +} + +public struct RecommendedBattleTagDTO: Decodable { + public let tagId: Int + public let name: String? +} + +public struct RecommendedBattleOptionDTO: Decodable { + public let optionId: Int + public let title: String? + public let stance: String? + public let representative: String? + public let imageUrl: String? +} + +public typealias RecommendedBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift new file mode 100644 index 00000000..e8c57969 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift @@ -0,0 +1,50 @@ +// +// RecommendedBattleDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension RecommendedBattlePageDataDTO { + func toDomain() -> RecommendedBattlePage { + RecommendedBattlePage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension RecommendedBattleDTO { + func toDomain() -> RecommendedBattle { + RecommendedBattle( + battleId: battleId, + title: title ?? "", + summary: summary ?? "", + audioDuration: audioDuration ?? 0, + viewCount: viewCount ?? 0, + tags: (tags ?? []).map { $0.toDomain() }, + participantsCount: participantsCount ?? 0, + options: (options ?? []).map { $0.toDomain() } + ) + } +} + +public extension RecommendedBattleTagDTO { + func toDomain() -> RecommendedBattleTag { + RecommendedBattleTag(tagId: tagId, name: name ?? "") + } +} + +public extension RecommendedBattleOptionDTO { + func toDomain() -> RecommendedBattleOption { + RecommendedBattleOption( + optionId: optionId, + title: title ?? "", + stance: stance ?? "", + representative: representative ?? "", + imageUrl: imageUrl + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 195e7617..f0b08674 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -117,7 +117,7 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { public func createPerspective( battleId: Int, content: String, - optionId: Int + optionId: Int? ) async throws -> BattlePerspective { let dto: CreatePerspectiveResponseDTO = try await provider.request( .createPerspective( @@ -169,4 +169,18 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + + public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { + let dto: RecommendedBattlePageResponseDTO = try await provider.request( + .recommendations(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "추천 배틀 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty recommendations payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index d2f65cbc..58f9c096 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -19,6 +19,7 @@ public enum BattleService { case perspectives(battleId: Int, query: PerspectivesQueryRequest) case createPerspective(battleId: Int, body: CreatePerspectiveRequest) case myPerspective(battleId: Int) + case recommendations(battleId: Int) } extension BattleService: BaseTargetType { @@ -44,6 +45,8 @@ extension BattleService: BaseTargetType { return BattleAPI.perspectives(battleId: battleId).description case let .myPerspective(battleId): return BattleAPI.myPerspective(battleId: battleId).description + case let .recommendations(battleId): + return BattleAPI.recommendations(battleId: battleId).description } } @@ -51,7 +54,7 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail, .scenario, .voteStats, .perspectives, .myPerspective: + case .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: return .get case .preVote, .postVote, .createPerspective: return .post @@ -77,10 +80,12 @@ extension BattleService: BaseTargetType { return body.toDictionary case .myPerspective: return nil + case .recommendations: + return nil } } public var headers: [String: String]? { - return APIHeader.baseHeader + return APIHeader.baseHeader } } diff --git a/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift index e185f68f..f9e94eec 100644 --- a/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift +++ b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift @@ -9,13 +9,21 @@ import Foundation public struct CreatePerspectiveRequest: Encodable { public let content: String - public let optionId: Int - - public init( - content: String, - optionId: Int - ) { + public let optionId: Int? + + public init(content: String, optionId: Int? = nil) { self.content = content self.optionId = optionId } + + private enum CodingKeys: String, CodingKey { + case content + case optionId + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(content, forKey: .content) + try container.encodeIfPresent(optionId, forKey: .optionId) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 2052255e..3627f339 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -29,9 +29,10 @@ public protocol BattleInterface: Sendable { func createPerspective( battleId: Int, content: String, - optionId: Int + optionId: Int? ) async throws -> BattlePerspective func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? + func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 1df166e8..4e618cc6 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -76,12 +76,12 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { public func createPerspective( battleId _: Int, content: String, - optionId: Int + optionId _: Int? ) async throws -> BattlePerspective { BattlePerspective( perspectiveId: 0, user: BattlePerspectiveUser(userTag: "", nickname: "나", characterType: "", characterImageUrl: nil), - option: BattlePerspectiveOption(optionId: optionId, label: nil, title: "", stance: ""), + option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), content: content, likeCount: 0, commentCount: 0, @@ -94,4 +94,8 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { public func fetchMyPerspective(battleId _: Int) async throws -> BattlePerspective? { nil } + + public func fetchRecommendedBattles(battleId _: Int) async throws -> RecommendedBattlePage { + RecommendedBattlePage(items: [], nextCursor: nil, hasNext: false) + } } diff --git a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift new file mode 100644 index 00000000..bbd9c55c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift @@ -0,0 +1,94 @@ +// +// RecommendedBattle.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. +// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. +// + +import Foundation + +public struct RecommendedBattlePage: Equatable { + public let items: [RecommendedBattle] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [RecommendedBattle], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct RecommendedBattle: Equatable, Identifiable, Hashable { + public let battleId: Int + public let title: String + public let summary: String + public let audioDuration: Int + public let viewCount: Int + public let tags: [RecommendedBattleTag] + public let participantsCount: Int + public let options: [RecommendedBattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + audioDuration: Int, + viewCount: Int, + tags: [RecommendedBattleTag], + participantsCount: Int, + options: [RecommendedBattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.audioDuration = audioDuration + self.viewCount = viewCount + self.tags = tags + self.participantsCount = participantsCount + self.options = options + } +} + +public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { + public let tagId: Int + public let name: String + + public var id: Int { tagId } + + public init(tagId: Int, name: String) { + self.tagId = tagId + self.name = name + } +} + +public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String? + + public var id: Int { optionId } + + public init( + optionId: Int, + title: String, + stance: String, + representative: String, + imageUrl: String? + ) { + self.optionId = optionId + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index 282e0c3c..30476cb9 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -60,7 +60,7 @@ public struct BattleUseCaseImpl: BattleInterface { public func createPerspective( battleId: Int, content: String, - optionId: Int + optionId: Int? ) async throws -> BattlePerspective { return try await battleRepository.createPerspective( battleId: battleId, @@ -72,6 +72,10 @@ public struct BattleUseCaseImpl: BattleInterface { public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { return try await battleRepository.fetchMyPerspective(battleId: battleId) } + + public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { + return try await battleRepository.fetchRecommendedBattles(battleId: battleId) + } } extension BattleUseCaseImpl: DependencyKey { From 1858d1435ae9471b010f4092a1f14f3a649f580c Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:50:39 +0900 Subject: [PATCH 37/42] =?UTF-8?q?design:=20=EA=B4=80=EC=A0=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=8C=9D=EC=97=85=20finalVote=20UI=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20+=20=EC=8B=A0=EA=B3=A0=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EC=A0=95=EB=A0=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Alert/CustomPopup/CustomAlertState.swift | 5 +- .../CustomConfirmationPopupView.swift | 55 ++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 76f4c6c6..1de4fa04 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -37,6 +37,7 @@ public enum CustomAlertStyle: Equatable { case finalVote case report case alreadyWatched + case deleteConfirm } @CasePathable @@ -87,7 +88,7 @@ public extension CustomAlertState where Action == CustomAlertAction { confirmTitle: "삭제하기", cancelTitle: "뒤로가기", isDestructive: true, - style: .confirmation + style: .deleteConfirm ) } @@ -97,7 +98,7 @@ public extension CustomAlertState where Action == CustomAlertAction { confirmTitle: "삭제하기", cancelTitle: "뒤로가기", isDestructive: true, - style: .confirmation + style: .deleteConfirm ) } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index d746e9b9..869fe439 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -60,7 +60,7 @@ struct CustomConfirmationPopup: View { switch style { case .confirmation: return 20 - case .finalVote, .report, .alreadyWatched: + case .finalVote, .report, .alreadyWatched, .deleteConfirm: return 0 } } @@ -76,9 +76,59 @@ struct CustomConfirmationPopup: View { reportContent case .alreadyWatched: alreadyWatchedContent + case .deleteConfirm: + deleteConfirmContent } } + private var deleteConfirmContent: some View { + VStack(spacing: 16) { + Text(title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral900) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + + HStack(spacing: 0) { + // 삭제하기 (왼쪽·밝은 버튼) = 확정(삭제) + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary500) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + // 뒤로가기 (오른쪽·어두운 버튼) = 취소 + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 20) + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(0.9) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .onTapGesture {} + } + @ViewBuilder private var alreadyWatchedContent: some View { VStack(spacing: 12) { @@ -241,8 +291,9 @@ struct CustomConfirmationPopup: View { reportButtons } .padding(.top, 20) - .frame(width: 343, height: 236) + .frame(width: 343) .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(.primary500, lineWidth: 1.5) From 72973b0ffd0d3f15608242489ba266266aa62b5e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:50:52 +0900 Subject: [PATCH 38/42] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80/=EB=8C=80?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=C2=B7=EC=8B=A0=EA=B3=A0=20+=20=EB=A9=94=EB=89=B4=20enum=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20+=20=EA=B0=B1=EC=8B=A0=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Perspective/PerspectiveAPI.swift | 6 + .../PerspectiveRepositoryImpl.swift | 22 +++ .../DefaultPerspectiveRepositoryImpl.swift | 4 + .../Perspective/PerspectiveInterface.swift | 2 + .../Perspective/PerspectiveUseCase.swift | 8 + .../Comment/Reducer/CommentFeature.swift | 158 +++++++++------- .../Sources/Comment/View/CommentView.swift | 61 ++++-- .../View/Components/CommentAvatarView.swift | 2 +- .../Reducer/CommentReplyFeature.swift | 174 +++++++++++++----- .../CommentReply/View/CommentReplyView.swift | 88 ++++++++- 10 files changed, 393 insertions(+), 132 deletions(-) diff --git a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift index aaa426c8..33633982 100644 --- a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift +++ b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift @@ -12,6 +12,8 @@ public enum PerspectiveAPI { case updateComment(perspectiveId: Int, commentId: Int) case deleteComment(perspectiveId: Int, commentId: Int) case likes(perspectiveId: Int) + case reports(perspectiveId: Int) + case reportComment(perspectiveId: Int, commentId: Int) public var description: String { switch self { @@ -19,6 +21,10 @@ public enum PerspectiveAPI { return "\(perspectiveId)" case let .likes(perspectiveId): return "\(perspectiveId)/likes" + case let .reports(perspectiveId): + return "\(perspectiveId)/reports" + case let .reportComment(perspectiveId, commentId): + return "\(perspectiveId)/comments/\(commentId)/reports" case let .listLabeledComments(perspectiveId): return "\(perspectiveId)/comments/labeled" case let .createComment(perspectiveId): diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift index 15558aea..03aa7dff 100644 --- a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -172,6 +172,28 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S return data.toDomain() } + + public func reportPerspective(perspectiveId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .reportPerspective(perspectiveId: perspectiveId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "관점 신고 실패" + throw CommentError.backendError(message) + } + } + + public func reportComment(perspectiveId: Int, commentId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .reportComment(perspectiveId: perspectiveId, commentId: commentId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "댓글 신고 실패" + throw CommentError.backendError(message) + } + } } public struct EmptyDTO: Decodable {} diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift index 04847d5a..7b5a82d0 100644 --- a/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift @@ -68,4 +68,8 @@ public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: false) } + + public func reportPerspective(perspectiveId _: Int) async throws {} + + public func reportComment(perspectiveId _: Int, commentId _: Int) async throws {} } diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift index 8ab7545f..2634b547 100644 --- a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -33,6 +33,8 @@ public protocol PerspectiveInterface: Sendable { func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult + func reportPerspective(perspectiveId: Int) async throws + func reportComment(perspectiveId: Int, commentId: Int) async throws } public struct PerspectiveRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift index 347894e9..c7fbccc0 100644 --- a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift @@ -76,6 +76,14 @@ public struct PerspectiveUseCaseImpl: PerspectiveInterface { public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { return try await perspectiveRepository.fetchPerspectiveLikes(perspectiveId: perspectiveId) } + + public func reportPerspective(perspectiveId: Int) async throws { + return try await perspectiveRepository.reportPerspective(perspectiveId: perspectiveId) + } + + public func reportComment(perspectiveId: Int, commentId: Int) async throws { + return try await perspectiveRepository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } } extension PerspectiveUseCaseImpl: DependencyKey { diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index a45d1a72..5b62d1e4 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -2,8 +2,6 @@ // CommentFeature.swift // Chat // -// 댓글 화면. vote-stats API 로 상단 통계만 실데이터 사용, -// 댓글 리스트는 아직 mock. // import Foundation @@ -90,19 +88,26 @@ public struct CommentFeature { case shareTapped case filterTapped(CommentFilter) case sortTapped(CommentSort) - case moreTapped(UUID) - case replyTapped(UUID) - case reportButtonTapped(UUID) - case reportConfirmTapped(UUID) case reportPopupDismissed case menuDismissed - case editTapped(UUID) - case deleteTapped(UUID) - case reportTapped(UUID) - case likeTapped(UUID) + case commentMenu(id: UUID, action: CommentMenuAction) + case commentRow(id: UUID, action: CommentRowAction) case sendTapped } + public enum CommentMenuAction: Equatable { + case more + case edit + case delete + case report + } + + public enum CommentRowAction: Equatable { + case openReply + case reportConfirm + case like + } + public enum AsyncAction: Equatable { case fetchBattle case fetchMyPerspective @@ -112,6 +117,7 @@ public struct CommentFeature { case createComment(content: String) case updatePerspective(perspectiveId: Int, content: String) case deletePerspective(perspectiveId: Int) + case reportPerspective(perspectiveId: Int) case fetchPerspectiveLikes(perspectiveId: Int) } @@ -134,6 +140,7 @@ public struct CommentFeature { public enum DelegateAction: Equatable { case dismiss case openReply(CommentItem) + case openCuration(battleId: Int) } nonisolated enum CancelID: Hashable { @@ -202,49 +209,62 @@ extension CommentFeature { return .send(.delegate(.dismiss)) case .forwardTapped: - // 앱바 우측 > : 첫 댓글의 대댓글(CommentReplyView) 화면으로 이동 - guard let comment = state.comments.first else { return .none } - return .send(.delegate(.openReply(comment))) + // 앱바 우측 > : 큐레이팅(흥미 기반 추천 배틀) 화면으로 이동 + return .send(.delegate(.openCuration(battleId: state.battleId))) case .shareTapped: return .none - case let .moreTapped(id), let .replyTapped(id): - guard let comment = state.comments.first(where: { $0.id == id }) else { return .none } - state.reportTargetCommentID = nil - return .send(.delegate(.openReply(comment))) - - case let .reportButtonTapped(id): - // "…" 탭 → 메뉴 표시 (내 글: 수정/삭제, 남 글: 신고) - state.menuTargetCommentID = id - return .none + case let .commentRow(id, action): + switch action { + case .openReply: + guard let comment = state.comments.first(where: { $0.id == id }) else { return .none } + state.reportTargetCommentID = nil + return .send(.delegate(.openReply(comment))) + case .reportConfirm: + state.reportTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + return .send(.async(.reportPerspective(perspectiveId: pid))) + case .like: + guard let index = state.comments.firstIndex(where: { $0.id == id }), + let commentId = state.comments[index].perspectiveId + else { return .none } + let wasLiked = state.comments[index].isLiked + state.comments[index].isLiked.toggle() + state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 + return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: wasLiked))) + } case .menuDismissed: state.menuTargetCommentID = nil return .none - case let .editTapped(id): - state.menuTargetCommentID = nil - guard let comment = state.comments.first(where: { $0.id == id }), - let pid = comment.perspectiveId - else { return .none } - state.editingPerspectiveId = pid - state.commentText = comment.content - return .none - - case let .deleteTapped(id): - state.menuTargetCommentID = nil - guard let comment = state.comments.first(where: { $0.id == id }), - let pid = comment.perspectiveId - else { return .none } - state.deleteTargetPerspectiveId = pid - state.customAlert = .deletePerspective() - return .none - - case let .reportTapped(id): - state.menuTargetCommentID = nil - state.reportTargetCommentID = id - state.customAlert = .report() + case let .commentMenu(id, action): + switch action { + case .more: + // "…" 탭 → 해당 댓글 바로 아래 메뉴 토글 (내 글: 수정/삭제, 남 글: 신고) + state.menuTargetCommentID = (state.menuTargetCommentID == id) ? nil : id + case .edit: + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.editingPerspectiveId = pid + state.commentText = comment.content + case .delete: + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.deleteTargetPerspectiveId = pid + state.customAlert = .deletePerspective() + case .report: + state.menuTargetCommentID = nil + state.reportTargetCommentID = id + state.customAlert = .report() + } return .none case .reportPopupDismissed: @@ -252,11 +272,6 @@ extension CommentFeature { state.customAlert = nil return .none - case let .reportConfirmTapped(id): - state.reportTargetCommentID = nil - Log.debug("[CommentFeature] report comment tapped: \(id)") - return .none - case let .filterTapped(filter): state.selectedFilter = filter state.reportTargetCommentID = nil @@ -267,15 +282,6 @@ extension CommentFeature { state.reportTargetCommentID = nil return .send(.async(.fetchPerspectives(reset: true))) - case let .likeTapped(id): - guard let index = state.comments.firstIndex(where: { $0.id == id }), - let commentId = state.comments[index].perspectiveId - else { return .none } - let wasLiked = state.comments[index].isLiked - state.comments[index].isLiked.toggle() - state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 - return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: wasLiked))) - case .sendTapped: let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !state.isSubmitting else { return .none } @@ -305,7 +311,7 @@ extension CommentFeature { return .send(.async(.deletePerspective(perspectiveId: pid))) } if let id = state.reportTargetCommentID { - return .send(.view(.reportConfirmTapped(id))) + return .send(.view(.commentRow(id: id, action: .reportConfirm))) } return .none @@ -410,8 +416,17 @@ extension CommentFeature { case let .createComment(content): let battleId = state.battleId - // 관점 등록 optionId: 내 관점/투표 진영 > 첫 옵션 순으로 결정. - let optionId = state.myOptionId ?? state.voteSummary.optionA.optionId + // 등록 진영: 현재 선택된 필터 탭의 옵션. 전체 탭이면 내 투표 진영(myOptionId). + let optionId: Int? = { + switch state.selectedFilter { + case .all: return state.myOptionId + case .optionA: return state.voteSummary.optionA.optionId > 0 ? state.voteSummary.optionA.optionId : state + .myOptionId + case .optionB: return state.voteSummary.optionB.optionId > 0 ? state.voteSummary.optionB.optionId : state + .myOptionId + } + }() + // 관점 등록: POST /battles/{id}/perspectives, body {content, optionId} return .run { [battle = battleUseCase] send in let result = await Result { try await battle.createPerspective(battleId: battleId, content: content, optionId: optionId) @@ -433,6 +448,11 @@ extension CommentFeature { await send(.inner(.mutationFinished)) } + case let .reportPerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportPerspective(perspectiveId: perspectiveId) + } + case let .fetchPerspectiveLikes(perspectiveId): return .run { [repository = perspectiveUseCase] send in let result = await Result { @@ -484,6 +504,12 @@ extension CommentFeature { case let .success(perspective): state.perspectiveId = perspective?.perspectiveId if let optionId = perspective?.option.optionId { state.myOptionId = optionId } + // 내 perspectiveId 가 늦게 로드돼도 기존 목록에서 내 글을 표시. + if let myPid = perspective?.perspectiveId { + for index in state.comments.indices where state.comments[index].perspectiveId == myPid { + state.comments[index].isMine = true + } + } case let .failure(error): Log.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)") } @@ -503,8 +529,14 @@ extension CommentFeature { state.isLoadingComments = false switch result { case let .success(page): - let mapped = page.items.enumerated().map { idx, item in - CommentItem(item: item, order: idx) + let myPid = state.perspectiveId + let mapped = page.items.enumerated().map { idx, item -> CommentItem in + var comment = CommentItem(item: item, order: idx) + // 서버 isMyPerspective 가 누락/false 여도 내 perspectiveId 와 일치하면 내 글로 판정. + if let myPid, comment.perspectiveId == myPid { + comment.isMine = true + } + return comment } state.comments = reset ? mapped : state.comments + mapped state.nextCursor = page.nextCursor diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 4c2d5da3..c499766c 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -63,28 +63,45 @@ public struct CommentView: View { } } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) - .overlay { - if let comment = store.menuTargetComment { - BottomActionSheet( - items: menuItems(for: comment), - onDismiss: { send(.menuDismissed) } - ) + } + + /// 댓글 카드 바로 아래에 뜨는 인라인 메뉴 (pill 형태). + @ViewBuilder + private func inlineMenu(for comment: CommentItem) -> some View { + VStack(alignment: .trailing, spacing: 8) { + ForEach(menuItems(for: comment)) { item in + Button { item.action() } label: { + HStack(spacing: 4) { + Image(systemName: item.systemImage) + .font(.system(size: 13, weight: .medium)) + Text(item.title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) } } - .animation(.easeInOut(duration: 0.2), value: store.menuTargetCommentID) + .padding(.trailing, 4) } /// "…" 메뉴 항목 — 내 글: 수정/삭제, 남 글: 신고. private func menuItems(for comment: CommentItem) -> [BottomActionItem] { if comment.isMine { return [ - BottomActionItem(title: "수정", systemImage: "pencil") { send(.editTapped(comment.id)) }, - BottomActionItem(title: "삭제", systemImage: "trash", isDestructive: true) { send(.deleteTapped(comment.id)) }, + BottomActionItem(title: "수정", systemImage: "pencil") { send(.commentMenu(id: comment.id, action: .edit)) }, + BottomActionItem(title: "삭제", systemImage: "trash", isDestructive: true) { send(.commentMenu( + id: comment.id, + action: .delete + )) }, ] } else { return [ - BottomActionItem(title: "신고", systemImage: "exclamationmark.bubble", isDestructive: true) { - send(.reportTapped(comment.id)) + BottomActionItem(title: "신고", systemImage: "light.beacon.max.fill", isDestructive: true) { + send(.commentMenu(id: comment.id, action: .report)) }, ] } @@ -322,8 +339,18 @@ private extension CommentView { VStack(spacing: 12) { ForEach(store.comments) { comment in commentCard(comment) + .overlay(alignment: .topTrailing) { + if store.menuTargetCommentID == comment.id { + inlineMenu(for: comment) + .padding(.trailing, 12) + .offset(y: 46) + .zIndex(1) + } + } + .zIndex(store.menuTargetCommentID == comment.id ? 1 : 0) } } + .animation(.easeInOut(duration: 0.18), value: store.menuTargetCommentID) } } .padding(.horizontal, 16) @@ -362,7 +389,7 @@ private extension CommentView { @ViewBuilder func commentBody(_ comment: CommentItem) -> some View { - Button { send(.replyTapped(comment.id)) } label: { + Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { Text(comment.content) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) @@ -388,7 +415,7 @@ private extension CommentView { func commentAuthorBlock(_ comment: CommentItem) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { - Text(comment.author) + Text(comment.isMine ? "나" : comment.author) .pretendardFont(family: .Medium, size: 14) .foregroundStyle(.neutral500) .lineLimit(1) @@ -418,7 +445,7 @@ private extension CommentView { @ViewBuilder func reportButton(commentId: UUID) -> some View { - Button { send(.reportButtonTapped(commentId)) } label: { + Button { send(.commentMenu(id: commentId, action: .more)) } label: { Image(systemName: "ellipsis") .font(.system(size: 18, weight: .regular)) .frame(width: 24, height: 24) @@ -452,7 +479,7 @@ private extension CommentView { @ViewBuilder func moreButton(commentId: UUID) -> some View { - Button { send(.moreTapped(commentId)) } label: { + Button { send(.commentRow(id: commentId, action: .openReply)) } label: { Text("더보기") .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) @@ -462,7 +489,7 @@ private extension CommentView { @ViewBuilder func replyCountButton(_ comment: CommentItem) -> some View { - Button { send(.replyTapped(comment.id)) } label: { + Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { actionLabel(systemName: "message", text: "\(comment.replyCount)") } .buttonStyle(.plain) @@ -470,7 +497,7 @@ private extension CommentView { @ViewBuilder func likeButton(_ comment: CommentItem) -> some View { - Button { send(.likeTapped(comment.id)) } label: { + Button { send(.commentRow(id: comment.id, action: .like)) } label: { actionLabel( systemName: comment.isLiked ? "heart.fill" : "heart", text: formattedCount(comment.likeCount) diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift index d75f3042..382caf9b 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift @@ -49,7 +49,7 @@ private extension CommentAvatarView { .placeholder { Color.beige600 } .resizable() .scaledToFill() - .frame(width: size, height: size) + .frame(width: 24, height: 24) .scaleEffect(imageScale) } else { Text(String(fallback.prefix(1))) diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index 163a853c..a7323a35 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -3,22 +3,17 @@ // Chat // // 대댓글 화면. -// - GET /api/v1/perspectives/{pid} (부모 댓글 상세) -// - GET /api/v1/perspectives/{pid}/comments/labeled (답글 페이지) -// - POST /api/v1/perspectives/{pid}/comments (답글 작성) -// - PUT /api/v1/perspectives/{pid}/comments/{cid} (답글 수정) -// - DELETE /api/v1/perspectives/{pid}/comments/{cid} (답글 삭제) -// - POST/DELETE /api/v1/comments/{cid}/likes (좋아요 토글) + // import Foundation import ComposableArchitecture +import DesignSystem import DomainInterface import Entity import LogMacro import UseCase -import DesignSystem @Reducer public struct CommentReplyFeature { @@ -37,8 +32,17 @@ public struct CommentReplyFeature { public var editingCommentId: Int? /// "…" 메뉴를 띄울 대상 답글 id. public var menuTargetReplyId: UUID? + /// 부모(관점) "…" 메뉴 열림. + public var parentMenuOpen: Bool = false + /// 입력창이 부모 관점 수정 모드. + public var editingParent: Bool = false /// 삭제 확인 알럿 대상 commentId. public var deleteTargetCommentId: Int? + /// 신고 확인 알럿 대상 commentId. + public var reportTargetCommentId: Int? + /// 삭제/신고 알럿이 부모(관점) 대상. + public var deleteParentPending: Bool = false + public var reportParentPending: Bool = false @Presents public var customAlert: CustomAlertState? public var menuTargetReply: CommentReplyItem? { @@ -78,11 +82,16 @@ public struct CommentReplyFeature { case beginEditing(commentId: Int, content: String) case cancelEditing case deleteTapped(commentId: Int) - case replyMoreTapped(UUID) case menuDismissed - case replyEditTapped(UUID) - case replyDeleteTapped(UUID) - case replyReportTapped(UUID) + case replyMenu(id: UUID, action: MenuAction) + case parentMenu(MenuAction) + } + + public enum MenuAction: Equatable { + case more + case edit + case delete + case report } @CasePathable @@ -99,6 +108,10 @@ public struct CommentReplyFeature { case toggleParentLike(currentlyLiked: Bool) case toggleReplyLike(commentId: Int, currentlyLiked: Bool) case fetchParentLikes + case reportComment(commentId: Int) + case updateParent(content: String) + case deleteParent + case reportParent } public enum InnerAction: Equatable { @@ -171,16 +184,36 @@ public struct CommentReplyFeature { switch customAlertAction { case .confirmTapped: state.customAlert = nil - guard let commentId = state.deleteTargetCommentId else { return .none } - state.deleteTargetCommentId = nil - return .send(.async(.deleteReply(commentId: commentId))) + if state.deleteParentPending { + state.deleteParentPending = false + return .send(.async(.deleteParent)) + } + if state.reportParentPending { + state.reportParentPending = false + return .send(.async(.reportParent)) + } + if let commentId = state.deleteTargetCommentId { + state.deleteTargetCommentId = nil + return .send(.async(.deleteReply(commentId: commentId))) + } + if let commentId = state.reportTargetCommentId { + state.reportTargetCommentId = nil + return .send(.async(.reportComment(commentId: commentId))) + } + return .none case .cancelTapped: state.deleteTargetCommentId = nil + state.reportTargetCommentId = nil + state.deleteParentPending = false + state.reportParentPending = false state.customAlert = nil return .none } case .dismiss: state.deleteTargetCommentId = nil + state.reportTargetCommentId = nil + state.deleteParentPending = false + state.reportParentPending = false state.customAlert = nil return .none } @@ -225,6 +258,10 @@ extension CommentReplyFeature { let text = state.replyText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return .none } state.replyText = "" + if state.editingParent { + state.editingParent = false + return .send(.async(.updateParent(content: text))) + } if let editingId = state.editingCommentId { state.editingCommentId = nil return .send(.async(.updateReply(commentId: editingId, content: text))) @@ -244,35 +281,59 @@ extension CommentReplyFeature { case let .deleteTapped(commentId): return .send(.async(.deleteReply(commentId: commentId))) - case let .replyMoreTapped(id): - state.menuTargetReplyId = id - return .none - case .menuDismissed: state.menuTargetReplyId = nil return .none - case let .replyEditTapped(id): - state.menuTargetReplyId = nil - guard let reply = state.replies.first(where: { $0.id == id }), - let commentId = reply.commentId - else { return .none } - state.editingCommentId = commentId - state.replyText = reply.content - return .none - - case let .replyDeleteTapped(id): - state.menuTargetReplyId = nil - guard let reply = state.replies.first(where: { $0.id == id }), - let commentId = reply.commentId - else { return .none } - state.deleteTargetCommentId = commentId - state.customAlert = .deleteComment() - return .none + case let .replyMenu(id, action): + switch action { + case .more: + state.menuTargetReplyId = (state.menuTargetReplyId == id) ? nil : id + return .none + case .edit: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.editingCommentId = commentId + state.replyText = reply.content + return .none + case .delete: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.deleteTargetCommentId = commentId + state.customAlert = .deleteComment() + return .none + case .report: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.reportTargetCommentId = commentId + state.customAlert = .report() + return .none + } - case let .replyReportTapped(id): - state.menuTargetReplyId = nil - Log.debug("[CommentReplyFeature] report reply tapped: \(id)") + case let .parentMenu(action): + switch action { + case .more: + state.parentMenuOpen.toggle() + case .edit: + state.parentMenuOpen = false + state.editingCommentId = nil + state.editingParent = true + state.replyText = state.parentComment.content + case .delete: + state.parentMenuOpen = false + state.deleteParentPending = true + state.customAlert = .deletePerspective() + case .report: + state.parentMenuOpen = false + state.reportParentPending = true + state.customAlert = .report() + } return .none } } @@ -300,6 +361,8 @@ extension CommentReplyFeature { case let .fetchReplies(reset): state.isLoadingReplies = true + // 갱신(reset) 시 리스트를 비워 스켈레톤이 노출되도록 한다. + if reset { state.replies = [] } let pid = state.perspectiveId let cursor = reset ? nil : state.nextCursor return .run { [repository = perspectiveUseCase] send in @@ -384,6 +447,32 @@ extension CommentReplyFeature { .mapError(CommentError.from) return await send(.inner(.parentLikesResponse(result))) } + + case let .reportComment(commentId): + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } + + case let .updateParent(content): + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + await send(.async(.fetchParent)) + } + + case .deleteParent: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + try? await repository.deletePerspective(perspectiveId: perspectiveId) + await send(.delegate(.dismiss)) + } + + case .reportParent: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportPerspective(perspectiveId: perspectiveId) + } } } } @@ -432,14 +521,13 @@ extension CommentReplyFeature { case let .updateResponse(result, _): switch result { - case let .success(payload): - if let index = state.replies.firstIndex(where: { $0.commentId == payload.commentId }) { - state.replies[index].content = payload.content - } + case .success: + // 수정 후 리스트 reset 갱신 → 스켈레톤 노출 + return .send(.async(.fetchReplies(reset: true))) case let .failure(error): Log.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)") + return .none } - return .none case let .deleteResponse(result): switch result { diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index 3658aa00..fee3c001 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -44,6 +44,55 @@ public struct CommentReplyView: View { .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .animation(.easeInOut(duration: 0.18), value: store.menuTargetReplyId) + } + + /// 답글 "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고) — pill 형태. + @ViewBuilder + private func replyMenu(for reply: CommentReplyItem) -> some View { + VStack(alignment: .trailing, spacing: 8) { + if reply.isMine { + menuPill(title: "수정", systemImage: "pencil") { send(.replyMenu(id: reply.id, action: .edit)) } + menuPill(title: "삭제", systemImage: "trash") { send(.replyMenu(id: reply.id, action: .delete)) } + } else { + menuPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.replyMenu(id: reply.id, action: .report)) } + } + } + } + + /// 부모(관점) "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고). + @ViewBuilder + private func parentMenuView() -> some View { + VStack(alignment: .trailing, spacing: 8) { + if store.parentComment.isMine { + menuPill(title: "수정", systemImage: "pencil") { send(.parentMenu(.edit)) } + menuPill(title: "삭제", systemImage: "trash") { send(.parentMenu(.delete)) } + } else { + menuPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.parentMenu(.report)) } + } + } + } + + @ViewBuilder + private func menuPill( + title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .medium)) + Text(title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) } } @@ -89,7 +138,7 @@ private extension CommentReplyView { func parentCommentSection() -> some View { VStack(spacing: 0) { commentCard( - author: store.parentComment.author, + author: store.parentComment.isMine ? "나" : store.parentComment.author, imageURL: store.parentComment.authorImageURL, timeAgo: store.parentComment.timeAgo, option: store.parentComment.option, @@ -101,11 +150,21 @@ private extension CommentReplyView { isMine: store.parentComment.isMine, background: .beige50, showsReplyCount: true, + moreAction: { send(.parentMenu(.more)) }, likeAction: { send(.parentLikeTapped) } ) + .overlay(alignment: .topTrailing) { + if store.parentMenuOpen { + parentMenuView() + .padding(.trailing, 12) + .offset(y: 46) + .zIndex(1) + } + } } .padding(12) .background(.beige50) + .zIndex(store.parentMenuOpen ? 1 : 0) .overlay(alignment: .bottom) { Rectangle() .fill(.beige600) @@ -125,7 +184,7 @@ private extension CommentReplyView { ForEach(store.replies) { reply in commentCard( - author: reply.author, + author: reply.isMine ? "나" : reply.author, imageURL: reply.authorImageURL, timeAgo: reply.timeAgo, option: reply.option, @@ -137,9 +196,18 @@ private extension CommentReplyView { isMine: reply.isMine, background: .beige50, showsReplyCount: false, - moreAction: { send(.replyMoreTapped(reply.id)) }, + moreAction: { send(.replyMenu(id: reply.id, action: .more)) }, likeAction: { send(.replyLikeTapped(reply.id)) } ) + .overlay(alignment: .topTrailing) { + if store.menuTargetReplyId == reply.id { + replyMenu(for: reply) + .padding(.trailing, 24) + .offset(y: 46) + .zIndex(1) + } + } + .zIndex(store.menuTargetReplyId == reply.id ? 1 : 0) } } .padding(.bottom, 24) @@ -214,7 +282,7 @@ private extension CommentReplyView { option: CommentOption, optionLabel: String, isMine: Bool, - moreAction _: (() -> Void)? + moreAction: (() -> Void)? ) -> some View { HStack(alignment: .top, spacing: 8) { CommentAvatarView( @@ -244,10 +312,14 @@ private extension CommentReplyView { Spacer() - Image(systemName: "ellipsis") - .font(.system(size: 18, weight: .regular)) - .frame(width: 24, height: 24) - .foregroundStyle(.neutral300) + Button { moreAction?() } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral300) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } From e931e32c629ba01477f201cbdf2ae40862ff2316 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:51:02 +0900 Subject: [PATCH 39/42] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8C=85(=ED=9D=A5=EB=AF=B8=20=EC=B6=94=EC=B2=9C=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80)=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80=20+=20Comm?= =?UTF-8?q?entView=20>=20=EC=A7=84=EC=9E=85=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Coordinator/Reducer/ChatCoordinator.swift | 16 ++ .../View/ChatCoordinatorView.swift | 3 + .../Curation/Reducer/CurationFeature.swift | 142 +++++++++++ .../Components/CurationSkeletonView.swift | 68 +++++ .../Sources/Curation/View/CurationView.swift | 238 ++++++++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift create mode 100644 Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift create mode 100644 Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 00e1ba60..8e2024b1 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -111,6 +111,21 @@ extension ChatCoordinator { case .routeAction(_, action: .commentReply(.delegate(.dismiss))): return .send(.view(.backAction)) + case let .routeAction(_, action: .comment(.delegate(.openCuration(battleId)))): + state.routes.push(.curation(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .curation(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .curation(.delegate(.close))): + // X : 채팅 플로우 전체를 빠져나가 앱 루트(홈)로 이동 + return .send(.delegate(.dismiss)) + + case let .routeAction(_, action: .curation(.delegate(.openBattle(battleId)))): + state.routes.push(.preVote(.init(battleId: battleId))) + return .none + default: return .none } @@ -149,6 +164,7 @@ extension ChatCoordinator { case chatRoom(ChatRoomFeature) case comment(CommentFeature) case commentReply(CommentReplyFeature) + case curation(CurationFeature) } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift index 67bf110d..5b302ac0 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -32,6 +32,9 @@ public struct ChatCoordinatorView: View { case let .commentReply(commentReplyStore): CommentReplyView(store: commentReplyStore) .toolbar(.hidden, for: .tabBar) + case let .curation(curationStore): + CurationView(store: curationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift b/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift new file mode 100644 index 00000000..0811987e --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift @@ -0,0 +1,142 @@ +// +// CurationFeature.swift +// Chat +// +// .pen `큐레이팅 화면` — 특정 배틀 기준 흥미로운 배틀 추천 목록. +// GET /api/v1/battles/{battleId}/recommendations/interesting +// + +import Foundation + +import ComposableArchitecture +import DomainInterface +import Entity +import LogMacro +import UseCase + +@Reducer +public struct CurationFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var battleId: Int + public var isLoading: Bool = false + public var battles: [RecommendedBattle] = [] + + public init(battleId: Int = 0) { + self.battleId = battleId + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backButtonTapped + case closeButtonTapped + case battleTapped(battleId: Int) + } + + public enum AsyncAction: Equatable { + case fetchRecommendations + } + + public enum InnerAction: Equatable { + case recommendationsResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case close + case openBattle(battleId: Int) + } + + nonisolated enum CancelID: Hashable { + case fetchRecommendations + } + + @Dependency(\.battleUseCase) private var battleUseCase + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate: + return .none + } + } + } +} + +extension CurationFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchRecommendations)) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .closeButtonTapped: + return .send(.delegate(.close)) + + case let .battleTapped(battleId): + return .send(.delegate(.openBattle(battleId: battleId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchRecommendations: + state.isLoading = true + let battleId = state.battleId + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.fetchRecommendedBattles(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.recommendationsResponse(result))) + } + .cancellable(id: CancelID.fetchRecommendations, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recommendationsResponse(result): + state.isLoading = false + switch result { + case let .success(page): + state.battles = page.items + case let .failure(error): + Log.error("[CurationFeature] recommendations failed: \(error.localizedDescription)") + state.battles = [] + } + return .none + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift b/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift new file mode 100644 index 00000000..e1b4b781 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift @@ -0,0 +1,68 @@ +// +// CurationSkeletonView.swift +// Chat +// +// 큐레이팅(추천 배틀) 리스트 로딩 placeholder. +// + +import SwiftUI + +import DesignSystem + +struct CurationSkeletonView: View { + var count: Int = 4 + + var body: some View { + VStack(spacing: 16) { + ForEach(0 ..< count, id: \.self) { _ in + card() + } + } + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 16) { + // meta + HStack(spacing: 10) { + SkeletonView(cornerRadius: 2).frame(width: 44, height: 18) + Spacer() + SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + } + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 14) + SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + } + // versus + HStack(spacing: 8) { + optionPlaceholder() + SkeletonView(cornerRadius: 12).frame(width: 24, height: 24) + optionPlaceholder() + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + private func optionPlaceholder() -> some View { + HStack(spacing: 4) { + SkeletonView(cornerRadius: 20).frame(width: 40, height: 40) + VStack(spacing: 2) { + SkeletonView(cornerRadius: 4).frame(width: 44, height: 11) + SkeletonView(cornerRadius: 4).frame(width: 30, height: 10) + } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift new file mode 100644 index 00000000..2bec5b91 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift @@ -0,0 +1,238 @@ +// +// CurationView.swift +// Chat +// +// .pen `큐레이팅 화면` — 흥미 기반 배틀 추천 목록. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: CurationFeature.self) +public struct CurationView: View { + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + header() + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if store.isLoading, store.battles.isEmpty { + CurationSkeletonView() + } else if store.battles.isEmpty { + emptyState() + } else { + ForEach(store.battles) { battle in + battleCard(battle) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 16) + } + } + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +// MARK: - Header + +private extension CurationView { + @ViewBuilder + func header() -> some View { + HStack(spacing: 0) { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + .foregroundStyle(.neutral900) + } + .buttonStyle(.plain) + + Spacer() + + Text("더 흥미로운 배틀도 있어요!") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + + Spacer() + + Button { send(.closeButtonTapped) } label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .regular)) + .frame(width: 24, height: 24) + .foregroundStyle(.neutral900) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige200) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Empty + +private extension CurationView { + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(.neutral300) + Text("추천할 배틀이 없어요") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral300) + } + .frame(maxWidth: .infinity) + .padding(.top, 80) + } +} + +// MARK: - Battle Card + +private extension CurationView { + @ViewBuilder + func battleCard(_ battle: RecommendedBattle) -> some View { + Button { send(.battleTapped(battleId: battle.battleId)) } label: { + VStack(alignment: .leading, spacing: 16) { + cardMeta(battle) + cardVersus(battle) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func cardMeta(_ battle: RecommendedBattle) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + if let tag = battle.tags.first { + Text("#\(tag.name)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + Spacer() + + HStack(spacing: 2) { + Image(systemName: "clock") + .font(.system(size: 11, weight: .regular)) + Text(durationText(battle.audioDuration)) + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + + HStack(spacing: 2) { + Image(systemName: "eye") + .font(.system(size: 11, weight: .regular)) + Text("\(battle.viewCount)") + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + } + + VStack(alignment: .leading, spacing: 4) { + Text(battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral500) + .lineSpacing(14 * 0.3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !battle.summary.isEmpty { + Text(battle.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral200) + .lineSpacing(12 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + @ViewBuilder + func cardVersus(_ battle: RecommendedBattle) -> some View { + HStack(spacing: 8) { + optionButton(battle.options[safe: 0]) + versusBadge() + optionButton(battle.options[safe: 1]) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func optionButton(_ option: RecommendedBattleOption?) -> some View { + HStack(spacing: 4) { + CommentAvatarView( + imageURL: option?.imageUrl, + fallback: option?.representative ?? "", + size: 40 + ) + + VStack(spacing: 2) { + Text(option?.title ?? "") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral500) + Text(option?.representative ?? "") + .pretendardFont(family: .Regular, size: 10) + .foregroundStyle(.neutral300) + } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + func versusBadge() -> some View { + Text("VS") + .pretendardFont(family: .Bold, size: 8) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + .background(.secondary200, in: Circle()) + } + + func durationText(_ seconds: Int) -> String { + let minutes = max(1, Int((Double(seconds) / 60).rounded())) + return "\(minutes)분" + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} From 9c425e87410dcbb55ef3407c5595018bb68a8728 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 14:57:13 +0900 Subject: [PATCH 40/42] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EB=A1=9C=EB=94=A9=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20floating=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EB=85=B8=EC=B6=9C=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioPlayerRepositoryImpl.swift | 11 ++++-- .../AudioPlayer/AudioPlayerInterface.swift | 6 ++-- .../ChatRoom/Reducer/ChatRoomFeature.swift | 32 ++++++++++++++--- .../Sources/ChatRoom/View/ChatRoomView.swift | 9 +++++ .../Sources/Color/ShapeStyle+.swift | 15 +++++++- .../UI/Floating/FloatingErrorView.swift | 36 +++++++++++++++++++ 6 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift diff --git a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift index 08388a0a..06e56afb 100644 --- a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift @@ -18,7 +18,8 @@ public final class AudioPlayerRepositoryImpl: AudioPlayerInterface, @unchecked S service = AudioPlayerService.shared } - public func load(url: URL) async { + @discardableResult + public func load(url: URL) async -> Bool { await service.load(url: url) } @@ -78,10 +79,16 @@ final class AudioPlayerService { } } - func load(url: URL) { + func load(url: URL) async -> Bool { let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) player.seek(to: .zero) + // 자산이 실제 재생 가능한지 확인 → 실패 시 오류 배너 노출 트리거. + do { + return try await item.asset.load(.isPlayable) + } catch { + return false + } } func play() { player.play() } diff --git a/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift index e8f9f2e0..051304b3 100644 --- a/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift @@ -10,7 +10,9 @@ import Foundation import WeaveDI public protocol AudioPlayerInterface: Sendable { - func load(url: URL) async + /// 음원을 로드하고 재생 가능 여부를 반환한다. (false = 오디오 로딩 실패) + @discardableResult + func load(url: URL) async -> Bool func play() async func pause() async func seek(to time: TimeInterval) async @@ -20,7 +22,7 @@ public protocol AudioPlayerInterface: Sendable { public struct DefaultAudioPlayerImpl: AudioPlayerInterface { public init() {} - public func load(url _: URL) async {} + public func load(url _: URL) async -> Bool { true } public func play() async {} public func pause() async {} public func seek(to _: TimeInterval) async {} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 1e22b4a8..0a78c8a2 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -10,9 +10,9 @@ import Foundation import ComposableArchitecture import DesignSystem import DomainInterface -import UseCase import Entity import LogMacro +import UseCase @Reducer public struct ChatRoomFeature { @@ -27,6 +27,8 @@ public struct ChatRoomFeature { public var playerDuration: TimeInterval = 0 public var battleId: Int = 0 public var isLoadingScenario: Bool = false + /// 오디오 로딩 실패 시 상단 floating 오류 배너 노출 여부. + public var hasAudioError: Bool = false /// 한 번 끝까지 재생된 콘텐츠는 이후 재진입 시 시킹/건너뛰기를 허용한다. public var hasFinishedListening: Bool = false public var hasPresentedFinalVoteAlert: Bool = false @@ -177,11 +179,11 @@ public struct ChatRoomFeature { public func nodeStartTime(for nodeId: Int) -> TimeInterval { guard let node = scenario?.nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } - return TimeInterval((node.scripts.map(\.startTimeMs).min() ?? 0)) / 1000 + return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 } public func nodeEndTime(for node: ScenarioNode) -> TimeInterval { - let start = TimeInterval((node.scripts.map(\.startTimeMs).min() ?? 0)) / 1000 + let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 return start + TimeInterval(node.audioDuration) } } @@ -219,6 +221,8 @@ public struct ChatRoomFeature { case scenarioResponse(Result) case playerTimeUpdated(TimeInterval) case playerDurationUpdated(TimeInterval) + case audioLoadFailed + case dismissAudioError } @CasePathable @@ -234,6 +238,7 @@ public struct ChatRoomFeature { nonisolated enum CancelID: Hashable { case fetchScenario case audioObserver + case audioErrorDismiss } @Dependency(\.battleUseCase) private var battleUseCase @@ -377,8 +382,13 @@ extension ChatRoomFeature { state.currentTime = 0 state.playerDuration = 0 state.isPlaying = true + state.hasAudioError = false return .run { [player = audioPlayer] send in - await player.load(url: url) + let isPlayable = await player.load(url: url) + guard isPlayable else { + await send(.inner(.audioLoadFailed)) + return + } let duration = await player.duration() if duration > 0 { await send(.inner(.playerDurationUpdated(duration))) @@ -449,6 +459,20 @@ extension ChatRoomFeature { case let .playerDurationUpdated(duration): state.playerDuration = duration return .none + + case .audioLoadFailed: + state.isPlaying = false + state.hasAudioError = true + // 3초 후 자동으로 배너 숨김. + return .run { send in + try? await Task.sleep(for: .seconds(3)) + await send(.inner(.dismissAudioError)) + } + .cancellable(id: CancelID.audioErrorDismiss, cancelInFlight: true) + + case .dismissAudioError: + state.hasAudioError = false + return .none } } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index 26322a1b..d8828992 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -45,6 +45,15 @@ public struct ChatRoomView: View { } } .background(Color.beige200.ignoresSafeArea()) + .overlay(alignment: .top) { + if store.hasAudioError { + FloatingErrorView(message: "오디오를 불러오는 중 문제가 발생했어요") + .padding(.top, 64) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } + } + .animation(.easeInOut(duration: 0.25), value: store.hasAudioError) .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index 500e2835..07a7ccea 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -4,8 +4,8 @@ import SwiftUI public extension ShapeStyle where Self == Color { - // MARK: - Primitive / Primary + static var primary50: Color { .init(hex: "F3EBE9") } static var primary100: Color { .init(hex: "E7D7D3") } static var primary200: Color { .init(hex: "D0AFA8") } @@ -18,6 +18,7 @@ public extension ShapeStyle where Self == Color { static var primary900: Color { .init(hex: "4E2A21") } // MARK: - Primitive / Secondary + static var secondary50: Color { .init(hex: "FCF8F1") } static var secondary100: Color { .init(hex: "F9F1E3") } static var secondary200: Color { .init(hex: "F3E3C7") } @@ -30,6 +31,7 @@ public extension ShapeStyle where Self == Color { static var secondary900: Color { .init(hex: "92784A") } // MARK: - Primitive / Beige + static var beige50: Color { .init(hex: "FEFEFD") } static var beige100: Color { .init(hex: "FDFCFB") } static var beige200: Color { .init(hex: "FBF9F7") } @@ -42,6 +44,7 @@ public extension ShapeStyle where Self == Color { static var beige900: Color { .init(hex: "B7A88B") } // MARK: - Primitive / Gray + static var gray50: Color { .init(hex: "EBEBEB") } static var gray100: Color { .init(hex: "D7D7D7") } static var gray200: Color { .init(hex: "B0AFAE") } @@ -54,6 +57,7 @@ public extension ShapeStyle where Self == Color { static var gray900: Color { .init(hex: "131212") } // MARK: - Compat / Neutral (alias of Gray) + static var neutral50: Color { .gray50 } static var neutral100: Color { .gray100 } static var neutral200: Color { .gray200 } @@ -66,18 +70,22 @@ public extension ShapeStyle where Self == Color { static var neutral900: Color { .gray900 } // MARK: - Primitive / Status + static var errorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } static var errorDefault: Color { .init(hex: "C92D33") } + static var errorStrong: Color { .init(hex: "D7110C") } static var warningDefault: Color { .init(hex: "FFB400") } static var warningAlpha: Color { .init(hex: "FFB400", alpha: 0.4) } // MARK: - Semantic / Background + static var bgBeige: Color { .beige200 } static var bgDefault: Color { .init(hex: "FAFAF9") } static var bgSubtle: Color { .init(hex: "F5F5F4") } static var bgSubtler: Color { .gray50 } // MARK: - Semantic / Text + static var textBody: Color { .gray400 } static var textDefault: Color { .gray800 } static var textError: Color { .errorDefault } @@ -89,6 +97,7 @@ public extension ShapeStyle where Self == Color { static var textSubtler: Color { .gray500 } // MARK: - Semantic / Border + static var borderBeigeDefault: Color { .beige600 } static var borderBeigeDisabled: Color { .beige500 } static var borderBeigeFocus: Color { .beige700 } @@ -101,6 +110,7 @@ public extension ShapeStyle where Self == Color { static var borderWarningDefault: Color { .init(hex: "FFB400", alpha: 0.4) } // MARK: - Semantic / Surface + static var surfaceBeigeDefault: Color { .beige50 } static var surfaceBeigeStrong: Color { .beige400 } static var surfaceBeigeSubtle: Color { .beige300 } @@ -108,6 +118,7 @@ public extension ShapeStyle where Self == Color { static var surfacePrimarySubtle: Color { .primary50 } // MARK: - Semantic / Action + static var actionBeigeDefault: Color { .beige300 } static var actionBeigePressed: Color { .beige400 } static var actionBeigeStrong: Color { .beige600 } @@ -121,12 +132,14 @@ public extension ShapeStyle where Self == Color { static var actionSecondaryDefault: Color { .secondary50 } // MARK: - Semantic / Icon + static var iconGrayDefault: Color { .gray900 } static var iconGrayInverse: Color { .beige50 } static var iconGraySubtle: Color { .gray300 } static var iconPrimaryDefault: Color { .primary500 } // MARK: - Component + static var avatarBackround: Color { .beige600 } static var badgeCounterBackground: Color { .gray500 } static var badgeCounterTextActive: Color { .beige50 } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift b/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift new file mode 100644 index 00000000..ccabae65 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift @@ -0,0 +1,36 @@ +// +// FloatingErrorView.swift +// DesignSystem +// +// .pen `채팅방_오류` 의 오류 메세지 floating 배너. +// 빨간 배경 + 경고 아이콘 + 메시지. 상단에 떠 있는 형태. +// + +import SwiftUI + +public struct FloatingErrorView: View { + private let message: String + + public init(message: String) { + self.message = message + } + + public var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + + Text(message) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige50) + .kerning(-0.35) + .lineSpacing(14 * 0.28) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.errorStrong, in: RoundedRectangle(cornerRadius: 9)) + .shadow(color: Color(hex: "5B0604").opacity(0.28), radius: 5.25, x: 0, y: 0) + } +} From 7ad33c958852a206d3dea148beac7fac32c92430 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 15:00:16 +0900 Subject: [PATCH 41/42] =?UTF-8?q?feat:=20Hifi=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=83=9D=EC=84=B1=20+=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TargetDependency+Module/Modules.swift | 1 + Projects/Presentation/Hifi/Project.swift | 18 +++++++++++++ Projects/Presentation/Hifi/Sources/Base.swift | 22 +++++++++++++++ .../Hifi/Tests/Sources/HifiTests.swift | 27 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 Projects/Presentation/Hifi/Project.swift create mode 100644 Projects/Presentation/Hifi/Sources/Base.swift create mode 100644 Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index b838c0ad..bd108ac0 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -29,6 +29,7 @@ public extension ModulePath { public static let name: String = "Presentation" + case Hifi } } diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift new file mode 100644 index 00000000..b07893cc --- /dev/null +++ b/Projects/Presentation/Hifi/Project.swift @@ -0,0 +1,18 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Hifi", + bundleId: .appBundleID(name: ".Hifi"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Presentation(implements: .Chat), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Hifi/Sources/Base.swift b/Projects/Presentation/Hifi/Sources/Base.swift new file mode 100644 index 00000000..b1d530b1 --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/Base.swift @@ -0,0 +1,22 @@ +// +// base.swift +// DDDAttendance. +// +// Created by Roy on 2026-06-04 +// Copyright © 2026 DDD , Ltd., All rights reserved. +// + +import SwiftUI + +struct BaseView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + .padding() + } +} + diff --git a/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift b/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift new file mode 100644 index 00000000..21c54d00 --- /dev/null +++ b/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift @@ -0,0 +1,27 @@ +// +// HifiTests.swift +// Presentation.HifiTests +// +// Created by Roy on 2026-06-04. +// + +import Testing +@testable import Hifi + +struct HifiTests { + + @Test + func hifiExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func hifiLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + From fdb84a9e9b6572fe3a6b6dfc5ed350da6aa604b2 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 15:09:04 +0900 Subject: [PATCH 42/42] =?UTF-8?q?docs:=20README=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=EC=A0=90/=EB=8C=80=EB=8C=93=EA=B8=80=C2=B7=ED=81=90=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=8C=85=C2=B7=EC=98=A4=EB=94=94=EC=98=A4=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B0=B0=EB=84=88=C2=B7Chat/Hifi=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B0=98=EC=98=81=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 324d36db..aeeb89d8 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,17 @@ ln -s AGENTS.md CLAUDE.md - **사전 투표 → 1:1 채팅 토론 → 사후 투표** 의 한 흐름 - **재투표** 로 가치관이 바뀌었는지 추적 - **리캡 카드** 자동 생성 + 공유 +- **채팅방 오디오 재생** + 로딩 실패 시 상단 floating 오류 배너(`FloatingErrorView`) -### 💬 토론 / 댓글 -- 채팅방형 1:1 토론 -- 콘텐츠별 댓글·대댓글 -- 신고·차단 +### 💬 토론 / 관점(댓글) +- 채팅방형 1:1 음성 토론 +- 관점(=댓글) 등록·수정·삭제 + 대댓글, 좋아요, 신고 +- 투표 진영(optionId)별 관점 등록 / 진영 탭 필터 +- 본인 글 "나" 표시 + 수정·삭제 메뉴, 등록·갱신 시 스켈레톤 -### 🧭 탐색 / 홈 +### 🧭 탐색 / 큐레이팅 / 홈 - 큐레이팅된 홈 피드 +- **흥미 기반 배틀 추천**(큐레이팅 화면) — `GET /battles/{id}/recommendations/interesting` - 카테고리·태그 탐색 - 토픽 검색 @@ -83,6 +86,8 @@ Picke-iOS/ │ ├── Presentation/ # 🎨 UI Layer │ │ ├── Auth/ # 로그인 / 코디네이터 / Toast │ │ ├── Home/ # 홈 피드 / 큐레이팅 / 스켈레톤 +│ │ ├── Chat/ # 사전·사후 투표 / 채팅방 / 관점·대댓글 / 큐레이팅 (ChatCoordinator) +│ │ ├── Hifi/ # Hi-Fi 프로토타입 모듈 │ │ ├── MainTab/ # 탭 라우팅 / GNB │ │ ├── Splash/ # 스플래시 │ │ └── Presentation/ # 공통 프레젠테이션 유틸 @@ -106,7 +111,7 @@ Picke-iOS/ │ │ └── ThirdPartys/ # AsyncMoya / WeaveDI 등 SPM 재노출 │ │ │ └── Shared/ # 🔧 Shared Layer -│ ├── DesignSystem/ # 공통 UI / 컬러 / 이미지 / Toast +│ ├── DesignSystem/ # 공통 UI / 컬러 토큰 / 이미지 / Toast / Floating 배너 / AudioPlayer / 팝업 │ ├── Shared/ # 공유 모델·확장 │ ├── ThirdParty/ # 써드파티 래퍼 │ └── Utill/ # 공통 유틸리티