feat: 빠른배틀(오늘의 배틀) — API 연동 + 화면/세로페이저/공유/ChatRoom 진입 + 댓글 진영 버그 수정#38
Conversation
| // DailyBattle.swift | ||
| // Entity | ||
| // | ||
| // 오늘의 배틀 화면 모델 — picke.pen `오늘의 배틀`. `BattleInfo` 에서 매핑. |
There was a problem hiding this comment.
🟠 [P2] Major
DailyBattle 엔티티는 주석에 명시된 대로 "오늘의 배틀 화면 모델"이며, BattleInfo 엔티티를 화면에 맞게 변환하는 static func from(_ info: BattleInfo) -> DailyBattle 메서드를 포함하고 있습니다. 이러한 화면 특화된 모델과 그 매핑 로직은 Presentation/Battle/Model과 같은 프리젠테이션 레이어에 위치해야 합니다. 현재 Domain/Entity 모듈에 정의되어 있어, Domain 레이어가 Presentation 레이어의 세부 사항에 의존하는 모듈 아키텍처 위반입니다.
| // 오늘의 배틀 화면 모델 — picke.pen `오늘의 배틀`. `BattleInfo` 에서 매핑. | |
| // DailyBattle 엔티티와 해당 `from` 매핑 메서드를 Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift (신규 생성) 등 Presentation 레이어로 이동시켜야 합니다. | |
| // 그리고 BattleFeature Reducer에서 해당 매핑을 수행해야 합니다. | |
| // Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift (예시) | |
| import Entity // BattleInfo 사용을 위해 필요 | |
| public struct DailyBattle: Equatable, Identifiable { /* ... */ } | |
| public extension DailyBattle { | |
| static func from(_ info: BattleInfo) -> DailyBattle { /* ... */ } | |
| } | |
| // Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift 내에서 사용 예시: | |
| // state.battles = page.items.map(DailyBattle.from) |
|
|
||
| public extension BattleScenario { | ||
| /// 화면에 노출할 노드들. `visibleNodeIds` 우선, 비어있으면 현재/시작 노드 폴백. | ||
| func chatVisibleNodes(visibleNodeIds: [Int], currentNodeId: Int?) -> [ScenarioNode] { |
There was a problem hiding this comment.
🟠 [P2] Major
BattleScenario 엔티티에 chatVisibleNodes와 같이 특정 프리젠테이션(ChatRoomFeature)에 특화된 로직을 확장하는 것은 모듈 아키텍처 원칙(Presentation → Domain)에 위배됩니다. 도메인 엔티티는 특정 UI나 프리젠테이션 레이어에 종속되지 않는 순수한 비즈니스 로직과 데이터 구조를 포함해야 합니다. 이 로직은 ChatRoomFeature 내의 private 메서드 또는 Presentation/Chat 모듈 내의 별도 매퍼/빌더에서 구현되어야 합니다.
| func chatVisibleNodes(visibleNodeIds: [Int], currentNodeId: Int?) -> [ScenarioNode] { | |
| // 이 로직은 BattleScenario 엔티티의 확장 기능에서 제거하고 Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift 내의 private 메서드로 옮겨야 합니다. | |
| // (이 PR에서 BattleScenario+.swift로 이동되기 전의 ChatRoomFeature에 있던 로직을 복원하는 것이 바람직합니다.) | |
| // Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift 내 추가 예시: | |
| private extension ChatRoomFeature.State { | |
| 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 } | |
| } | |
| } | |
| } |
| } | ||
|
|
||
| /// 대사(script) → 화면 발화자(ChatSpeaker) 매핑. | ||
| func chatSpeaker(for script: ScenarioScript) -> ChatSpeaker { |
There was a problem hiding this comment.
🟠 [P2] Major
chatSpeaker와 같이 Chat 프리젠테이션 레이어의 특정 뷰 모델/엔티티(ChatSpeaker는 Presentation/Chat 모듈에 정의됨)를 생성하는 로직이 Domain/Entity 모듈에 위치하는 것은 모듈 의존성 역전을 야기하며 아키텍처 원칙에 위배됩니다. Domain 레이어는 Presentation 레이어를 알지 못해야 합니다. 이 매핑 로직은 ChatRoomFeature 내의 private 메서드 또는 Presentation/Chat 모듈 내의 별도 매퍼/빌더에서 구현되어야 합니다.
| func chatSpeaker(for script: ScenarioScript) -> ChatSpeaker { | |
| // 이 로직은 BattleScenario 엔티티의 확장 기능에서 제거하고 Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift 내의 private 메서드로 옮겨야 합니다. | |
| // (이 PR에서 BattleScenario+.swift로 이동되기 전의 ChatRoomFeature에 있던 로직을 복원하는 것이 바람직합니다.) | |
| // Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift 내 추가 예시: | |
| private extension ChatRoomFeature.State { | |
| 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) | |
| case .b: | |
| return speaker(label: "B", side: .right, fallbackName: script.speakerName, in: scenario) | |
| case .narrator: | |
| return ChatSpeaker(name: script.speakerName, side: .center) | |
| case .philosopher, .unknown: | |
| if let philosopher = scenario.philosophers.first(where: { $0.name == script.speakerName }) { | |
| let side: ChatSpeakerSide = philosopher.label == "B" ? .right : .left | |
| return ChatSpeaker( | |
| label: philosopher.label, | |
| name: philosopher.name, | |
| imageURL: philosopher.imageUrl, | |
| side: side | |
| ) | |
| } | |
| return ChatSpeaker(name: script.speakerName, side: .center) | |
| } | |
| } | |
| private func speaker( | |
| label: String, | |
| side: ChatSpeakerSide, | |
| fallbackName: String, | |
| in scenario: BattleScenario | |
| ) -> ChatSpeaker { | |
| guard let philosopher = scenario.philosophers.first(where: { $0.label == label }) else { | |
| return ChatSpeaker(label: label, name: fallbackName, side: side) | |
| } | |
| return ChatSpeaker( | |
| label: philosopher.label, | |
| name: philosopher.name, | |
| imageURL: philosopher.imageUrl, | |
| side: side | |
| ) | |
| } | |
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f7ec083f6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case let .routeAction(_, action: .chatRoom(.delegate(.requestFinalVote(battleId)))): | ||
| state.routes.push(.chat(.init(battleId: battleId))) |
There was a problem hiding this comment.
Route final vote into the post-vote screen
When a user enters a quick battle, finishes the ChatRoom, and accepts the final-vote prompt, this pushes a fresh ChatCoordinator.State(battleId:); that initializer roots the flow at PreVoteFeature with the default pre-vote mode, whereas the normal ChatCoordinator chat-room path pushes .preVote(.init(battleId: battleId, voteMode: .post)). In this quick-battle path the user is sent back to the pre-vote screen, and submitting it opens another ChatRoom instead of submitting the final vote/reaching comments.
Useful? React with 👍 / 👎.
| Text(tab.title) | ||
| .pretendardFont(family: .Medium, size: 12) | ||
| .foregroundStyle(.gray200) | ||
| } icon: { | ||
| tabIcon(for: tab) | ||
| } | ||
| .foregroundStyle(.gray200) |
There was a problem hiding this comment.
Don't pin selected tab labels to the inactive color
Because the custom tab label always applies .foregroundStyle(.gray200) to the Label, the currently selected tab is rendered with the same inactive color even though the router is tinted to .neutral900. This affects every tab after the custom label is used, so users lose the visual indication of which tab is active; let the selected tint/appearance drive the color instead of hard-coding gray here.
Useful? React with 👍 / 👎.
오늘의 배틀 화면 모델과 채팅방 전용 시나리오 매핑을 Presentation 레이어로 되돌려 Domain 의존 방향을 유지한다. Rejected: BattleScenario Entity 확장에 ChatSpeaker 매핑 유지 | Presentation 타입 의존이 생김
vote-stats 응답 배열 순서가 배틀 상세 옵션 순서와 달라도 상세에서 채운 optionId 기준으로 A/B 요약을 유지한다.
커스텀 탭 Label에서 gray200을 고정하지 않고 선택/미선택 색상은 UITabBarAppearance와 tint 설정을 따르게 한다.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27df5037b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case let .enterBattleTapped(battleId): | ||
| // 선택해야만 입장 가능. 입장 시 투표 확정 → 복귀 시 옵션 변경 비활성화. | ||
| guard state.selectedOptionByBattle[battleId] != nil else { return .none } | ||
| state.votedBattleIds.insert(battleId) | ||
| return .send(.delegate(.openBattle(battleId: battleId))) |
There was a problem hiding this comment.
Submit the quick-battle vote before entering
When a user picks an option and taps “배틀 입장하기”, this marks the battle as voted locally and navigates to ChatRoom, but the selected optionId is never sent to submitPreVote or passed to the chat flow. In the quick-battle path, the server therefore has no pre-vote/perspective for the user even though the UI has disabled further option changes, so later comment/final-vote state that depends on the user's selected side is wrong.
Useful? React with 👍 / 👎.
| let optionAId = state.voteSummary.optionA.optionId | ||
| let mapped = page.items.enumerated().map { idx, item -> CommentItem in | ||
| var comment = CommentItem(item: item, order: idx) | ||
| if optionAId > 0 { | ||
| comment.option = item.option.optionId == optionAId ? .a : .b |
There was a problem hiding this comment.
Re-map comments after option ids are available
This only fixes the A/B side when voteSummary.optionA.optionId is already populated, but onAppear starts fetchBattle, fetchVoteStats, and fetchPerspectives concurrently. If the perspectives response arrives first, optionAId is still the default 0, so comments keep the old label-based fallback; for the non-A/B labels this patch is addressing, the list and filters remain misclassified until another fetch happens. Gate the perspectives fetch on the battle/options response or re-map/refetch once the option ids arrive.
Useful? React with 👍 / 👎.
요약
빠른배틀 탭(오늘의 배틀) 신규 화면 +
/battles/todayAPI 연동, 댓글 진영 버그 수정.작업 내용
오늘의 배틀 API (
GET /api/v1/battles/today)today) → DTO/Mapper(TodayBattlePageDataDTO,BattleInfoDTO재사용) → Entity(TodayBattlePage) → Interface/UseCase/Repository(fetchTodayBattles)tags누락 →BattleOptionDTO.tags옵셔널화 (디코딩 에러 수정)오늘의 배틀 화면 (picke.pen 매칭)
공용화 / 네비
ShareItem/ShareContent→ Entity 모듈 이동(공용), 공유 시트 높이 50% 통일댓글 진영 버그 수정 (Comment)
optionId가 0이면 배틀상세 값 유지 → 투표 진영으로 잘못 등록되던 버그 수정.a/.b를 label 문자열이 아닌 optionId 로 판별 → 반대 진영 표시 버그 수정Refs #8