From a8b08dbb2f91c28dbccf82894b0ac3d36a3c6131 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 19 May 2026 23:34:56 +0900 Subject: [PATCH 01/34] =?UTF-8?q?ci:=20Gemini=20=EB=A6=AC=EB=B7=B0=20promp?= =?UTF-8?q?t=20=EC=A0=95=EB=B9=84=20=E2=80=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20AGENTS.md=20=EA=B7=9C=EC=B9=99=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20+=20nitpick=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwiftUI Convention 항목을 프로젝트 AGENTS.md 의 `@ViewBuilder private func/var` 패턴이 정식임을 명시하도록 수정 (SubView struct 강제 의견 절대 생성 금지) - Ignore 목록 확장 — 빈 줄/whitespace/trailing comma/주석 줄/import 순서/들여쓰기/`description` 같은 BaseTargetType 정식 네이밍 의견 모두 제외 - Severity Format 에서 P5 (Nitpick) 등급 완전 제거, P1 ~ P4 만 사용 - JSON example body label `[P1~P5]` → `[P1~P4]` --- .github/workflows/gemini-code-review.yml | 14 ++++++++++---- .../Sources/ChatRoom/Reducer/ChatRoomFeature.swift | 0 .../Home/Sources/ChatRoom/View/ChatRoomView.swift | 0 3 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift create mode 100644 Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift diff --git a/.github/workflows/gemini-code-review.yml b/.github/workflows/gemini-code-review.yml index e8fe47f..59e114d 100644 --- a/.github/workflows/gemini-code-review.yml +++ b/.github/workflows/gemini-code-review.yml @@ -145,7 +145,7 @@ jobs: [Review Criteria] 1. TCA Convention: Verify @Reducer + @ObservableState usage; Action naming describes events that occurred (e.g., xxxButtonTapped, xxxResponse), NOT intended effects (e.g., performLogin, loadData); Effect is .none when no side effect and .run for async work; shared logic lives in private methods, NOT shared Actions; Effect.run must NOT capture entire @ObservableState (extract needed values first); Reducer must NOT perform CPU-intensive work (offload to Effect); Store.scope must use stored property paths only (no computed transforms); Navigation uses @Reducer enum; transient UI state (hover, focus, animation) stays in SwiftUI @State, not TCA State. 2. Module Architecture: Respect Presentation → Domain ← Data dependency direction; Network is only imported by Data; module boundaries expose protocols (DomainInterface / DataInterface); DTO-to-Entity mapping stays in Data layer. - 3. SwiftUI Convention: SubViews are structs (NOT @ViewBuilder functions); use @Binding when a SubView mutates parent @State; no "View" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions. + 3. SwiftUI Convention (이 프로젝트는 AGENTS.md 규칙을 따른다 — SubView 분리는 `@ViewBuilder private func` 또는 `private var` 형태가 정식이며 위반이 아니다. SubView 를 struct 로 강제하는 의견은 절대 내지 말 것); use @Binding when a SubView mutates parent @State; no "View" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions. 4. Swift Code Quality: guard early return with shorthand optional binding (guard let value else { ... }) followed by a blank line; final class by default; private first (avoid fileprivate unless required); never force unwrap; operator line break puts operator at the start of the next line; function params line-break with closing paren on its own line; ternary for simple return/assignment only, split on '?'; [weak self] + guard let self else { return } in closures; constant groups as private enum (Metric/Font/Constant), NOT struct; empty collection literals ([] / [:]); indent 4 spaces; 120-char line limit. 5. Actionable Feedback: When improvement is needed you MUST provide a concrete Swift fix using GitHub's \`\`\`suggestion block. @@ -155,11 +155,17 @@ jobs: - 🟠 [P2] Major: module dependency-direction violations (e.g., Domain importing Data), Effect.run capturing entire @ObservableState, sharing logic through Actions, Store.scope with computed property, serious concurrency or error-mapping issues - 🟡 [P3] Minor: Action naming that describes intent/effect (performLogin, loadData, setRecords), SubView written as @ViewBuilder function, Swift API Design Guideline violations on public APIs, inefficient Effect composition - 🔵 [P4] Readability: View-suffix naming, Spacer() misuse, missing final / private, guard / ternary / line-break style violations, constant groups declared as struct instead of enum - - ⚪ [P5] Nitpick: typos, whitespace, formatting + (P5 Nitpick 등급은 사용하지 않는다. 빈 줄·공백·trailing comma·주석 줄 같은 포맷 의견은 절대 만들지 말 것.) Format: "🔴 **[P1] Critical**\\n\\nActual comment content here..." - Ignore comments and formatting-only changes. Write all review comments in Korean using Markdown, without greetings or closings. + Ignore the following entirely — these are NOT review issues for this project: + - blank lines, whitespace, trailing commas, comment-only lines (P5 nitpicks) + - SubView 를 struct 로 분리하라는 의견 (이 프로젝트는 AGENTS.md 의 `@ViewBuilder` 패턴이 정식) + - import 순서, 줄 단위 들여쓰기 + - `description` 같은 BaseTargetType 의 정식 프로퍼티 네이밍 의견 + + Only report substantive issues — bugs, architecture violations, security/concurrency risks. Do NOT generate P5 nitpicks. Write all review comments in Korean using Markdown, without greetings or closings. Respond ONLY with a JSON object in this exact format: { @@ -169,7 +175,7 @@ jobs: "path": "file path relative to repo root (from the b/ prefix in diff)", "line": , "code_snippet": "the actual code content from that line", - "body": "🔴/🟠/🟡/🔵/⚪ **[P1~P5] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 \\\`\\\`\\\`suggestion 블록 포함)" + "body": "🔴/🟠/🟡/🔵 **[P1~P4] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 \\\`\\\`\\\`suggestion 블록 포함)" } ] } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift new file mode 100644 index 0000000..e69de29 diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift new file mode 100644 index 0000000..e69de29 From 6ec934c70cb4245183622d5733996ef39ae9d15b Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 00:23:21 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat:=20#6=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B3=A8=EA=B2=A9=20+=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity: ChatSpeakerSide / ChatSpeaker / ChatMessage / ChatRoomBundle (.mock 7개 메시지, .pen `채팅방` k3lIx 1:1) - ChatRoomFeature: bundle/isPlaying/currentTime State + back · refresh · togglePlay · seek(±15) · scrub · DelegateAction.dismiss - ChatRoomView: PickeNavigationBar 헤더(back + 배틀 타이틀 + refresh) · 같은 speaker 메시지 그룹화한 좌/우 말풍선 · 진행 progress + 시간 + AudioPlayerControlView - DesignSystem: AudioPlayerControlView (.pen `Group 26` 매핑) — backward.end · play/pause · forward.end + "15초" 라벨, 채팅방 외에도 오디오 재생 필요한 화면에서 재사용 가능 --- .../Entity/Sources/Home/ChatMessage.swift | 90 +++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 120 ++++++++++ .../Sources/ChatRoom/View/ChatRoomView.swift | 223 ++++++++++++++++++ .../AudioPlayer/AudioPlayerControlView.swift | 80 +++++++ 4 files changed, 513 insertions(+) create mode 100644 Projects/Domain/Entity/Sources/Home/ChatMessage.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift new file mode 100644 index 0000000..42966f1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift @@ -0,0 +1,90 @@ +// +// ChatMessage.swift +// Entity +// +// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. +// + +import Foundation + +public enum ChatSpeakerSide: Equatable, Hashable { + case left + case right +} + +public struct ChatSpeaker: Equatable, Identifiable, Hashable { + public let philosopher: PhilosopherAvatar + public let side: ChatSpeakerSide + + public var id: PhilosopherAvatar { philosopher } + + public init(philosopher: PhilosopherAvatar, side: ChatSpeakerSide) { + self.philosopher = philosopher + self.side = side + } +} + +public struct ChatMessage: Equatable, Identifiable, Hashable { + public let messageId: UUID + public let speaker: ChatSpeaker + public let text: String + + public var id: UUID { messageId } + + public init( + messageId: UUID = UUID(), + speaker: ChatSpeaker, + text: String + ) { + self.messageId = messageId + self.speaker = speaker + self.text = text + } +} + +public struct ChatRoomBundle: Equatable { + public let battleTitle: String + public let totalDuration: TimeInterval + public let leftSpeaker: ChatSpeaker + public let rightSpeaker: ChatSpeaker + public let messages: [ChatMessage] + + public init( + battleTitle: String, + totalDuration: TimeInterval, + leftSpeaker: ChatSpeaker, + rightSpeaker: ChatSpeaker, + messages: [ChatMessage] + ) { + self.battleTitle = battleTitle + self.totalDuration = totalDuration + self.leftSpeaker = leftSpeaker + self.rightSpeaker = rightSpeaker + self.messages = messages + } +} + +public extension ChatRoomBundle { + static let mock: ChatRoomBundle = { + let plato = ChatSpeaker(philosopher: .plato, side: .left) + let sartre = ChatSpeaker(philosopher: .sartre, side: .right) + return ChatRoomBundle( + battleTitle: "뒤샹의 변기, 예술인가 도발인가", + totalDuration: 268, + leftSpeaker: plato, + rightSpeaker: sartre, + messages: [ + ChatMessage(speaker: plato, text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다."), + ChatMessage(speaker: plato, text: "변기의 이데아는 '배설물을 처리하는 것'이지, 감상하는 것이 아닙니다."), + ChatMessage(speaker: plato, text: "사물의 본질을 왜곡하여 대중을 혼란에 빠뜨리는 것은 진리를 모독하는 행위입니다."), + ChatMessage(speaker: sartre, text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다."), + ChatMessage(speaker: sartre, text: "인간이 그것을 어떻게 사용하고 어떤 의미를 부여하느냐에 따라 본질은 언제든 바뀔 수 있습니다."), + ChatMessage( + speaker: sartre, + text: "뒤샹이 이 물건을 '샘'이라고 부르기로 선택한 순간, 이 물체의 본질은 배설 도구에서 예술 작품으로 재탄생한 것입니다. 실존은 본질에 앞서니까요." + ), + ChatMessage(speaker: plato, text: "예술이란 이데아를 모방하려는 숭고한 노력입니다. 화가는 붓질을 통해, 조각가는 망치질을 통해 그 본질에 가까워지려 애쓰죠."), + ] + ) + }() +} diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index e69de29..a841660 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -0,0 +1,120 @@ +// +// ChatRoomFeature.swift +// Home +// +// Created by Wonji Suh on 5/19/26. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro + +@Reducer +public struct ChatRoomFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var bundle: ChatRoomBundle = .mock + public var isPlaying: Bool = false + public var currentTime: TimeInterval = 0 + public var battleId: Int = 0 + + public var totalDuration: TimeInterval { bundle.totalDuration } + public var battleTitle: String { bundle.battleTitle } + public var messages: [ChatMessage] { bundle.messages } + + public init() {} + } + + 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 refreshTapped + case togglePlayTapped + case seekBackwardTapped + case seekForwardTapped + case scrub(TimeInterval) + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + .none + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + handleAsyncAction(state: &state, action: asyncAction) + case let .inner(innerAction): + handleInnerAction(state: &state, action: innerAction) + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ChatRoomFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .none + case .backButtonTapped: + return .send(.delegate(.dismiss)) + case .refreshTapped: + state.currentTime = 0 + state.isPlaying = false + return .none + case .togglePlayTapped: + state.isPlaying.toggle() + return .none + case .seekBackwardTapped: + state.currentTime = max(0, state.currentTime - 15) + return .none + case .seekForwardTapped: + state.currentTime = min(state.totalDuration, state.currentTime + 15) + return .none + case let .scrub(time): + state.currentTime = min(max(0, time), state.totalDuration) + return .none + } + } + + private func handleAsyncAction(state _: inout State, action: AsyncAction) -> Effect { + switch action {} + } + + private func handleInnerAction(state _: inout State, action: InnerAction) -> Effect { + switch action {} + } + + private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { + switch action { + case .dismiss: + .none + } + } +} diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index e69de29..664fc07 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -0,0 +1,223 @@ +// +// ChatRoomView.swift +// Home +// +// Created by Wonji Suh on 5/19/26. +// +// .pen `채팅방` (k3lIx) + 와이어프레임 매핑 — 헤더 + 메시지 리스트 + 오디오 재생바. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: ChatRoomFeature.self) +public struct ChatRoomView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar() + messageList() + playerBar() + } + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +// MARK: - Navigation + +extension ChatRoomView { + @ViewBuilder + private func navigationBar() -> some View { + PickeNavigationBar( + onBack: { send(.backButtonTapped) } + ) { + Button { send(.refreshTapped) } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .overlay(alignment: .center) { + Text(store.battleTitle) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral800) + .lineLimit(1) + .padding(.horizontal, 56) + } + .foregroundStyle(.neutral800) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Messages + +extension ChatRoomView { + @ViewBuilder + private func messageList() -> some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + ForEach(groupedMessages, id: \.id) { group in + messageGroup(group) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var groupedMessages: [SpeakerGroup] { + var result: [SpeakerGroup] = [] + for message in store.messages { + if let last = result.last, last.speaker == message.speaker { + var updated = last + updated.messages.append(message) + result[result.count - 1] = updated + } else { + result.append(SpeakerGroup(speaker: message.speaker, messages: [message])) + } + } + return result + } + + @ViewBuilder + private func messageGroup(_ group: SpeakerGroup) -> some View { + HStack(alignment: .top, spacing: 8) { + if group.speaker.side == .left { + avatar(group.speaker) + bubbleColumn(speaker: group.speaker, messages: group.messages) + Spacer(minLength: 40) + } else { + Spacer(minLength: 40) + bubbleColumn(speaker: group.speaker, messages: group.messages) + avatar(group.speaker) + } + } + } + + @ViewBuilder + private func avatar(_ speaker: ChatSpeaker) -> some View { + Image(asset: speaker.philosopher.imageAsset) + .resizable() + .scaledToFit() + .frame(width: 16, height: 28) + .frame(width: 40, height: 40) + .background(.beige600, in: Circle()) + } + + @ViewBuilder + private func bubbleColumn(speaker: ChatSpeaker, messages: [ChatMessage]) -> some View { + VStack(alignment: speaker.side == .left ? .leading : .trailing, spacing: 6) { + Text(speaker.philosopher.rawValue) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral800) + .padding(.horizontal, 4) + + VStack(alignment: .leading, spacing: 6) { + ForEach(messages) { message in + bubble(text: message.text, side: speaker.side) + } + } + } + } + + @ViewBuilder + private func bubble(text: String, side: ChatSpeakerSide) -> some View { + Text(text) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + side == .left ? Color.beige50 : Color.beige400, + in: RoundedRectangle(cornerRadius: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(side == .left ? Color.beige600 : Color.beige700, lineWidth: 1) + ) + } + + private struct SpeakerGroup: Equatable, Identifiable { + let id = UUID() + let speaker: ChatSpeaker + var messages: [ChatMessage] + } +} + +// MARK: - Player Bar + +extension ChatRoomView { + @ViewBuilder + private func playerBar() -> some View { + VStack(spacing: 16) { + progressBar() + AudioPlayerControlView( + isPlaying: .constant(store.isPlaying), + onBackward: { send(.seekBackwardTapped) }, + onTogglePlay: { send(.togglePlayTapped) }, + onForward: { send(.seekForwardTapped) } + ) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + .background(.beige50) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + @ViewBuilder + private func progressBar() -> some View { + VStack(spacing: 8) { + GeometryReader { proxy in + let progress = store.totalDuration > 0 + ? CGFloat(store.currentTime / store.totalDuration) + : 0 + ZStack(alignment: .leading) { + Capsule().fill(.beige600).frame(height: 4) + Capsule().fill(.primary500).frame(width: proxy.size.width * progress, height: 4) + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + .offset(x: max(0, proxy.size.width * progress - 5)) + } + } + .frame(height: 10) + + HStack { + Text(timeString(store.currentTime)) + Spacer() + Text(timeString(store.totalDuration)) + } + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + } + + private func timeString(_ seconds: TimeInterval) -> String { + let total = Int(seconds) + return String(format: "%d:%02d", total / 60, total % 60) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift new file mode 100644 index 0000000..2e3d3e2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift @@ -0,0 +1,80 @@ +// +// AudioPlayerControlView.swift +// DesignSystem +// +// .pen `Group 26` (재생바 컨트롤 3 버튼) 공통 컴포넌트. +// 채팅방 / 배틀 상세 등 오디오 플레이백이 필요한 화면에서 재사용. +// + +import SwiftUI + +public struct AudioPlayerControlView: View { + @Binding private var isPlaying: Bool + private let onBackward: () -> Void + private let onTogglePlay: () -> Void + private let onForward: () -> Void + + public init( + isPlaying: Binding, + onBackward: @escaping () -> Void, + onTogglePlay: @escaping () -> Void, + onForward: @escaping () -> Void + ) { + _isPlaying = isPlaying + self.onBackward = onBackward + self.onTogglePlay = onTogglePlay + self.onForward = onForward + } + + public var body: some View { + HStack(spacing: 32) { + backwardButton() + playButton() + forwardButton() + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private func backwardButton() -> some View { + Button(action: onBackward) { + VStack(spacing: 4) { + Image(systemName: "backward.end.fill") + .font(.system(size: 28)) + .foregroundStyle(.primary800) + Text("15초") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + .frame(width: 55, height: 55) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func playButton() -> some View { + Button(action: onTogglePlay) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 28)) + .foregroundStyle(.neutral900) + .frame(width: 55, height: 55) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func forwardButton() -> some View { + Button(action: onForward) { + VStack(spacing: 4) { + Image(systemName: "forward.end.fill") + .font(.system(size: 28)) + .foregroundStyle(.primary800) + Text("15초") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + .frame(width: 55, height: 55) + } + .buttonStyle(.plain) + } +} From 14085fa9258ecc039bf92ba65ebc334f7560d2a5 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 00:37:46 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat:=20=EC=82=AC=EC=A0=84=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=ED=88=AC=ED=91=9C=20API=20=EC=97=B0=EB=8F=99=20+?= =?UTF-8?q?=20=ED=88=AC=ED=91=9C=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=9D=B4=EB=8F=99=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/battles/{battleId}/votes/pre 연동 - BattleAPI / BattleService / PreVoteRequest 추가 - PreVoteResult Entity + DTO 매퍼 - BattleRepositoryImpl 추가 + WeaveDI 등록 - PreVoteFeature: submitPreVote 액션 + voteSubmitted 델리게이트 - selectedSide → poll.options 의 optionId 매핑 - HomeCoordinator: chatRoom 라우트 추가 - 투표 완료 델리게이트 수신 시 ChatRoomView push - swiftformat:disable extensionAccessControl 로 @Reducer 매크로 호환 유지 --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Data/API/Sources/Base/PieckeDomain.swift | 5 +- .../Data/API/Sources/Battle/BattleAPI.swift | 17 ++++++ .../Sources/Battle/DTO/PreVoteDataDTO.swift | 13 +++++ .../Battle/Mapper/PreVoteDataDTO+.swift | 16 ++++++ .../Sources/Battle/BattleRepositoryImpl.swift | 40 +++++++++++++ .../Sources/Battle/BattleService.swift | 56 +++++++++++++++++++ .../Sources/Battle/BattleInterface.swift | 31 ++++++++++ .../Battle/DefaultBattleRepositoryImpl.swift | 15 +++++ .../Entity/Sources/Home/PreVoteBattle.swift | 6 +- .../Entity/Sources/Home/PreVoteResult.swift | 27 +++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 4 +- .../Coordinator/Reducer/HomeCoordinator.swift | 14 ++++- .../View/HomeCoordinatorView.swift | 3 + .../Sources/Vote/Reducer/PreVoteFeature.swift | 45 ++++++++++++++- 15 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 Projects/Data/API/Sources/Battle/BattleAPI.swift create mode 100644 Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Battle/BattleService.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift create mode 100644 Projects/Domain/Entity/Sources/Home/PreVoteResult.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 0576dda..bde7187 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 { PollRepositoryImpl() as PollInterface } + .register { BattleRepositoryImpl() as BattleInterface } // .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 5d26192..5e09565 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -14,6 +14,7 @@ public enum PieckeDomain { case profile case home case poll + case battle } extension PieckeDomain: DomainType { @@ -30,7 +31,9 @@ extension PieckeDomain: DomainType { case .home: return"api/v1/home" case .poll: - return"api/v1/poll" + return "api/v1/poll" + case .battle: + return "api/v1/battles/" } } } diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift new file mode 100644 index 0000000..7c5b0b1 --- /dev/null +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -0,0 +1,17 @@ +// +// BattleAPI.swift +// API +// + +import Foundation + +public enum BattleAPI { + case preVote(battleId: Int) + + public var description: String { + switch self { + case let .preVote(battleId): + "\(battleId)/votes/pre" + } + } +} diff --git a/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift new file mode 100644 index 0000000..56b3aa0 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift @@ -0,0 +1,13 @@ +// +// PreVoteDataDTO.swift +// Model +// + +import Foundation + +public struct PreVoteDataDTO: Decodable { + public let voteId: Int + public let status: String +} + +public typealias PreVoteResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift new file mode 100644 index 0000000..2afaf86 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift @@ -0,0 +1,16 @@ +// +// PreVoteDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension PreVoteDataDTO { + func toDomain() -> PreVoteResult { + PreVoteResult( + voteId: voteId, + status: PreVoteResultStatus(rawValue: status) + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift new file mode 100644 index 0000000..5b053e9 --- /dev/null +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -0,0 +1,40 @@ +// +// BattleRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + let dto: PreVoteResponseDTO = try await provider.request( + .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "사전 투표 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty preVote payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift new file mode 100644 index 0000000..2f7e85a --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -0,0 +1,56 @@ +// +// BattleService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum BattleService { + case preVote(battleId: Int, body: PreVoteRequest) +} + +extension BattleService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .battle } + + public var urlPath: String { + switch self { + case let .preVote(battleId, _): + BattleAPI.preVote(battleId: battleId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .preVote: + .post + } + } + + public var parameters: [String: Any]? { + switch self { + case let .preVote(_, body): + body.toDictionary + } + } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} + +public struct PreVoteRequest: Encodable { + public let optionId: Int + + public init(optionId: Int) { + self.optionId = optionId + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift new file mode 100644 index 0000000..32ec97c --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -0,0 +1,31 @@ +// +// BattleInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol BattleInterface: Sendable { + func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult +} + +public struct BattleRepositoryDependency: DependencyKey { + public static var liveValue: BattleInterface { + UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + + public static var testValue: BattleInterface { + UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + + public static var previewValue: BattleInterface = liveValue +} + +public extension DependencyValues { + var battleRepository: BattleInterface { + get { self[BattleRepositoryDependency.self] } + set { self[BattleRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift new file mode 100644 index 0000000..a535a23 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -0,0 +1,15 @@ +// +// DefaultBattleRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultBattleRepositoryImpl: BattleInterface { + public init() {} + + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + PreVoteResult(voteId: 0, status: .none) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift index 4fa840f..61d1163 100644 --- a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift @@ -43,12 +43,14 @@ public struct PreVoteBattle: Equatable, Identifiable { } public struct PreVoteOption: Equatable, Identifiable, Hashable { + public let optionId: Int public let philosopher: PhilosopherAvatar public let stance: String - public var id: String { philosopher.rawValue } + public var id: String { "\(optionId)-\(philosopher.rawValue)" } - public init(philosopher: PhilosopherAvatar, stance: String) { + public init(optionId: Int = 0, philosopher: PhilosopherAvatar, stance: String) { + self.optionId = optionId self.philosopher = philosopher self.stance = stance } diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift new file mode 100644 index 0000000..3e4baf1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift @@ -0,0 +1,27 @@ +// +// PreVoteResult.swift +// Entity +// + +import Foundation + +public struct PreVoteResult: Equatable, Hashable { + public let voteId: Int + public let status: PreVoteResultStatus + + public init(voteId: Int, status: PreVoteResultStatus) { + self.voteId = voteId + self.status = status + } +} + +public enum PreVoteResultStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case created = "CREATED" + case updated = "UPDATED" + case unknown + + public init(rawValue: String) { + self = PreVoteResultStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index a841660..a68388b 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -26,7 +26,9 @@ public struct ChatRoomFeature { public var battleTitle: String { bundle.battleTitle } public var messages: [ChatMessage] { bundle.messages } - public init() {} + public init(battleId: Int = 0) { + self.battleId = battleId + } } public enum Action: ViewAction, BindableAction { diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 61bee05..0b8c8ef 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -64,8 +64,14 @@ extension HomeCoordinator { state.routes.push(.preVote(.init())) return .none - case .routeAction(_, action: .preVote(.delegate(.dismiss))), - .routeAction(_, action: .preVote(.delegate(.submit))): + case .routeAction(_, action: .preVote(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, _)))): + state.routes.push(.chatRoom(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): return .send(.view(.backAction)) default: @@ -88,12 +94,16 @@ extension HomeCoordinator { } } +// swiftformat:disable extensionAccessControl extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) case preVote(PreVoteFeature) + case chatRoom(ChatRoomFeature) } } +// swiftformat:enable extensionAccessControl + extension HomeCoordinator.HomeScreen.State: Equatable {} diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index a109a6d..ea1b80c 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -27,6 +27,9 @@ public struct HomeCoordinatorView: View { case let .preVote(preVoteStore): PreVoteView(store: preVoteStore) .toolbar(.hidden, for: .tabBar) + case let .chatRoom(chatRoomStore): + ChatRoomView(store: chatRoomStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift index 9a395e6..f3719f5 100644 --- a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift @@ -25,6 +25,7 @@ public struct PreVoteFeature { public var isSubmitting: Bool = false public var shareItem: ShareItem? public var pollId: Int = 1 + public var battleId: Int = 41 public var isPrimaryButtonEnabled: Bool { selectedSide != nil && !isSubmitting @@ -63,22 +64,26 @@ public struct PreVoteFeature { public enum AsyncAction: Equatable { case fetchPoll + case submitPreVote(battleId: Int, optionId: Int) } public enum InnerAction: Equatable { case pollResponse(Result) + case preVoteResponse(Result) } public enum DelegateAction: Equatable { case dismiss - case submit(pollId: Int, side: PhilosopherAvatar) + case voteSubmitted(battleId: Int, result: PreVoteResult) } nonisolated enum CancelID: Hashable { case fetchPoll + case submitPreVote } @Dependency(\.pollRepository) private var pollRepository + @Dependency(\.battleRepository) private var battleRepository public var body: some Reducer { BindingReducer() @@ -133,11 +138,24 @@ extension PreVoteFeature { case .primaryButtonTapped: guard let side = state.selectedSide else { return .none } + guard let optionId = optionId(for: side, in: state.battle) else { + Log.error("[PreVoteFeature] optionId 매핑 실패 side=\(side)") + return .none + } state.isSubmitting = true - return .send(.delegate(.submit(pollId: state.pollId, side: side))) + return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) } } + private func optionId( + for side: PhilosopherAvatar, + in battle: PreVoteBattle + ) -> Int? { + if battle.leftOption.philosopher == side { return battle.leftOption.optionId } + if battle.rightOption.philosopher == side { return battle.rightOption.optionId } + return nil + } + private func handleAsyncAction( state: inout State, action: AsyncAction @@ -154,6 +172,16 @@ extension PreVoteFeature { return await send(.inner(.pollResponse(result))) } .cancellable(id: CancelID.fetchPoll, cancelInFlight: true) + + case let .submitPreVote(battleId, optionId): + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.submitPreVote(battleId: battleId, optionId: optionId) + } + .mapError(AuthError.from) + return await send(.inner(.preVoteResponse(result))) + } + .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) } } @@ -172,6 +200,16 @@ extension PreVoteFeature { Log.error("[PreVoteFeature] fetchPoll failed: \(error.localizedDescription)") } return .none + + case let .preVoteResponse(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] submitPreVote failed: \(error.localizedDescription)") + return .none + } } } @@ -185,6 +223,7 @@ extension PreVoteFeature { let philosophers: [PhilosopherAvatar] = [.plato, .sartre, .sunja] let mapped = poll.options.enumerated().map { idx, option in PreVoteOption( + optionId: option.optionId, philosopher: philosophers[safe: idx] ?? .plato, stance: option.title ) @@ -209,7 +248,7 @@ extension PreVoteFeature { action: DelegateAction ) -> Effect { switch action { - case .dismiss, .submit: + case .dismiss, .voteSubmitted: .none } } From 7d7246adc93e94e550d91161e0b7399789b3be82 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 00:58:42 +0900 Subject: [PATCH 04/34] =?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=20progress=20bar=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8/=ED=83=AD=20=EC=8B=9C=ED=82=B9=20+=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=B3=B4=EA=B0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomView.progressBar 에 DragGesture(minimumDistance: 0) 부착 - 가로 위치 비율 → currentTime 매핑 후 .scrub(time) 전송 - 썸 크기 16px 로 확대 + contentShape 으로 트랙 전체 히트박스 - AGENTS.md: - Coordinator 섹션에 swiftformat:disable extensionAccessControl 디렉티브 필수 명시 - DomainType url switch 모든 case 에 return 명시 규칙 추가 --- AGENTS.md | 49 +++++++++++++++++++ .../Sources/Battle/PreVoteRequest.swift | 0 .../Sources/ChatRoom/View/ChatRoomView.swift | 18 +++++-- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 Projects/Data/Service/Sources/Battle/PreVoteRequest.swift diff --git a/AGENTS.md b/AGENTS.md index 2f62e84..e0bb9e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -474,13 +474,16 @@ public struct HomeCoordinator { // … State / Action / handleRoute … } +// swiftformat:disable extensionAccessControl extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) case preVote(PreVoteFeature) + case chatRoom(ChatRoomFeature) } } +// swiftformat:enable extensionAccessControl extension HomeCoordinator.HomeScreen.State: Equatable {} @@ -489,15 +492,61 @@ public struct HomeCoordinator { @Reducer public enum HomeScreen { ... } // 안 됨 (매크로 인식 / Route 추론 깨짐) } + +// ❌ 금지 — 자동 포맷터가 `public extension { enum }` 으로 바꾸도록 방치 +public extension HomeCoordinator { + @Reducer + enum HomeScreen { ... } // @Reducer 매크로 확장이 internal 로 생성 → "must be declared public" 에러 +} ``` 규칙: - `XScreen` 은 `@Reducer public enum`. struct 로 바꾸지 말 것 - 본체와 분리된 **별도 extension** 안에 선언 +- **swiftformat 자동 변환 차단**: 해당 블록 앞뒤로 `// swiftformat:disable extensionAccessControl` / `// swiftformat:enable extensionAccessControl` 주석 페어 필수. + - 포맷터가 `public extension X { enum XScreen }` 으로 끌어올리면 `@Reducer` 매크로가 만드는 `State` / `Action` / `body` 가 internal 로 생성되어 `enum 'State' must be declared public because it matches a requirement in public protocol 'CaseReducer'` 빌드 에러가 난다. - `extension Coordinator.XScreen.State: Equatable {}` 보조 conformance 도 같이 유지 (Route diff 비교 필요) - 라우터 핸들러 (`routerAction`) 안에서 `state.routes.push/pop/goBack` 직접 호출은 OK, 단 `dismiss`/`submit` 같이 반복되는 종료 액션은 `.send(.view(.backAction))` 으로 일원화 - 레퍼런스: `HomeCoordinator`, `AuthCoordinator`, `MainTabCoordinator` +#### 🌐 DomainType `url` switch — 모든 case 에 `return` 명시 유지 + +`PieckeDomain` 같은 `DomainType` 의 `url: String` computed property 는 **모든 case 에 `return` 키워드를 명시한다**. 자동 포맷터가 single-expression switch 규칙으로 `return` 을 떼어내려고 하지만, 새 case 추가 시 컴파일 에러 메시지가 끊기고 가독성도 망가지므로 **수동으로라도 되돌려야** 한다. + +```swift +// ✅ 올바른 패턴 — 모든 case 에 return 명시 +extension PieckeDomain: DomainType { + public var url: String { + switch self { + case .auth: + return "api/v1/auth/" + case .profile: + return "api/v1/me/" + case .home: + return "api/v1/home" + case .poll: + return "api/v1/poll" + case .battle: + return "api/v1/battles/" + } + } +} + +// ❌ 금지 — 포맷터가 떼어낸 implicit return (혼합 상태) +public var url: String { + switch self { + case .auth: "api/v1/auth/" // ← 안 됨 + case .poll: return "api/v1/poll" // ← 안 됨 (혼합) + } +} +``` + +규칙: +- 새 case 를 추가했는데 포맷터가 기존 case 의 `return` 을 떼어냈다면 **PR 전에 직접 되돌려서 일관성 유지** +- 새 도메인 case (`.battle` 등) 도 동일하게 `return "..."` 형태로 작성 +- 포맷터의 `redundantReturn` 룰이 자꾸 깨면 해당 파일에 `// swiftformat:disable redundantReturn` 디렉티브 페어 추가 검토 +- 레퍼런스: `Projects/Data/API/Sources/Base/PieckeDomain.swift` + ### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`) - Swift 스타일 가이드 - 에러 처리 패턴 diff --git a/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift b/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift new file mode 100644 index 0000000..e69de29 diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 664fc07..e71c62f 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -118,7 +118,7 @@ extension ChatRoomView { Image(asset: speaker.philosopher.imageAsset) .resizable() .scaledToFit() - .frame(width: 16, height: 28) + .frame(width: 30, height: 40) .frame(width: 40, height: 40) .background(.beige600, in: Circle()) } @@ -200,11 +200,21 @@ extension ChatRoomView { Capsule().fill(.primary500).frame(width: proxy.size.width * progress, height: 4) Circle() .fill(.primary500) - .frame(width: 10, height: 10) - .offset(x: max(0, proxy.size.width * progress - 5)) + .frame(width: 16, height: 16) + .offset(x: max(0, proxy.size.width * progress - 8)) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + guard proxy.size.width > 0, store.totalDuration > 0 else { return } + let ratio = min(max(value.location.x / proxy.size.width, 0), 1) + send(.scrub(store.totalDuration * Double(ratio))) + } + ) } - .frame(height: 10) + .frame(height: 16) HStack { Text(timeString(store.currentTime)) From 5031e6642f4bf4ef6ac4bd34710245fb9ea9394a Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 01:00:35 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=9E=AC=EC=83=9D(tick)=20+=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=88=20=EC=99=84=EC=B2=AD=20=ED=9B=84=EC=97=90=EB=A7=8C?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8/=EC=8B=9C=ED=82=B9=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomFeature - ContinuousClock 기반 1초 tick → currentTime 증가 - 재생 종료 시 hasFinishedListening = true, 자동 정지 - 재생 중에는 seekBackward / seekForward / scrub 잠금 (canScrub gate) - togglePlayTapped / refreshTapped / backButton 시 tick 시작·정지 동기화 - ChatRoomView.progressBar - allowsHitTesting(canScrub) 로 드래그 차단 + opacity 0.6 시각 피드백 --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 56 ++++++++++++++++--- .../Sources/ChatRoom/View/ChatRoomView.swift | 6 +- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index a68388b..d650f47 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -21,10 +21,13 @@ public struct ChatRoomFeature { public var isPlaying: Bool = false public var currentTime: TimeInterval = 0 public var battleId: Int = 0 + /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 + public var hasFinishedListening: Bool = false public var totalDuration: TimeInterval { bundle.totalDuration } public var battleTitle: String { bundle.battleTitle } public var messages: [ChatMessage] { bundle.messages } + public var canScrub: Bool { hasFinishedListening } public init(battleId: Int = 0) { self.battleId = battleId @@ -50,13 +53,25 @@ public struct ChatRoomFeature { case scrub(TimeInterval) } - public enum AsyncAction: Equatable {} - public enum InnerAction: Equatable {} + public enum AsyncAction: Equatable { + case startTicking + case stopTicking + } + + public enum InnerAction: Equatable { + case tick + } public enum DelegateAction: Equatable { case dismiss } + nonisolated enum CancelID: Hashable { + case tick + } + + @Dependency(\.continuousClock) var clock + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -85,32 +100,57 @@ extension ChatRoomFeature { case .onAppear: return .none case .backButtonTapped: - return .send(.delegate(.dismiss)) + return .send(.async(.stopTicking)).concatenate(with: .send(.delegate(.dismiss))) case .refreshTapped: state.currentTime = 0 state.isPlaying = false - return .none + return .send(.async(.stopTicking)) case .togglePlayTapped: state.isPlaying.toggle() - return .none + return state.isPlaying ? .send(.async(.startTicking)) : .send(.async(.stopTicking)) case .seekBackwardTapped: + guard state.canScrub else { return .none } state.currentTime = max(0, state.currentTime - 15) return .none case .seekForwardTapped: + guard state.canScrub else { return .none } state.currentTime = min(state.totalDuration, state.currentTime + 15) return .none case let .scrub(time): + guard state.canScrub else { return .none } state.currentTime = min(max(0, time), state.totalDuration) return .none } } private func handleAsyncAction(state _: inout State, action: AsyncAction) -> Effect { - switch action {} + switch action { + case .startTicking: + .run { [clock] send in + for await _ in clock.timer(interval: .seconds(1)) { + await send(.inner(.tick)) + } + } + .cancellable(id: CancelID.tick, cancelInFlight: true) + + case .stopTicking: + .cancel(id: CancelID.tick) + } } - private func handleInnerAction(state _: inout State, action: InnerAction) -> Effect { - switch action {} + private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + switch action { + case .tick: + let next = state.currentTime + 1 + if next >= state.totalDuration { + state.currentTime = state.totalDuration + state.isPlaying = false + state.hasFinishedListening = true + return .send(.async(.stopTicking)) + } + state.currentTime = next + return .none + } } private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index e71c62f..7a0881d 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -208,11 +208,15 @@ extension ChatRoomView { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in - guard proxy.size.width > 0, store.totalDuration > 0 else { return } + guard store.canScrub, + proxy.size.width > 0, + store.totalDuration > 0 else { return } let ratio = min(max(value.location.x / proxy.size.width, 0), 1) send(.scrub(store.totalDuration * Double(ratio))) } ) + .allowsHitTesting(store.canScrub) + .opacity(store.canScrub ? 1.0 : 0.6) } .frame(height: 16) From a5082f7507d19221bb5fd1a55c915d7cd41ec223 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 01:18:17 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20API=20=EC=97=B0=EB=8F=99=20+=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20onAppear=20=EC=8B=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/battles/{battleId}/scenario 연동 - BattleAPI / BattleService.scenario 추가 (GET) - BattleScenarioDataDTO + 매퍼 (philosophers / nodes / scripts / interactiveOptions) - BattleScenario Entity + ScenarioPhilosopher / ScenarioNode / ScenarioScript / ScenarioInteractiveOption - ScenarioSpeakerType / RecommendedPathKey enum (unknown fallback) - BattleInterface.fetchScenario + Default/Repository 구현 - ChatRoomFeature - State.scenario / isLoadingScenario 추가, battleTitle 은 scenario.title 우선 - onAppear → fetchScenario 비동기 호출 (cancellable) - scenarioResponse(Result) 핸들러로 단일 분기 - 부수: DI 키 computed property 에 explicit return 일관화 (Auth/Home/Poll/OAuth Provider) --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/BattleScenarioDataDTO.swift | 48 ++++++ .../Mapper/BattleScenarioDataDTO+.swift | 59 ++++++++ .../Sources/Battle/BattleRepositoryImpl.swift | 14 ++ .../Sources/Battle/BattleService.swift | 15 +- .../Sources/Battle/PreVoteRequest.swift | 16 ++ .../Sources/Auth/AuthInterface.swift | 4 +- .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 13 ++ .../Google/GoogleOAuthProviderInterface.swift | 4 +- .../Sources/Home/HomeInterface.swift | 4 +- .../Kakao/KakaoOAuthProviderInterface.swift | 4 +- .../Kakao/KakaoOAuthRepositoryProtocol.swift | 2 +- .../Sources/Poll/PollInterface.swift | 4 +- .../Entity/Sources/Home/BattleScenario.swift | 139 ++++++++++++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 40 ++++- .../Sources/Vote/Reducer/PreVoteFeature.swift | 2 +- 17 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Home/BattleScenario.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 7c5b0b1..8c5cc3e 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -7,11 +7,14 @@ import Foundation public enum BattleAPI { case preVote(battleId: Int) + case scenario(battleId: Int) public var description: String { switch self { case let .preVote(battleId): "\(battleId)/votes/pre" + case let .scenario(battleId): + "\(battleId)/scenario" } } } diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift new file mode 100644 index 0000000..1468bed --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift @@ -0,0 +1,48 @@ +// +// BattleScenarioDataDTO.swift +// Model +// + +import Foundation + +public struct BattleScenarioDataDTO: Decodable { + public let battleId: Int + public let title: String + public let philosophers: [ScenarioPhilosopherDTO] + public let isInteractive: Bool + public let startNodeId: Int + public let recommendedPathKey: String + public let audios: [String: String] + public let nodes: [ScenarioNodeDTO] +} + +public struct ScenarioPhilosopherDTO: Decodable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String +} + +public struct ScenarioNodeDTO: Decodable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScriptDTO] + public let interactiveOptions: [ScenarioInteractiveOptionDTO]? +} + +public struct ScenarioScriptDTO: Decodable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: String + public let speakerName: String + public let text: String +} + +public struct ScenarioInteractiveOptionDTO: Decodable { + public let label: String + public let nextNodeId: Int +} + +public typealias BattleScenarioResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift new file mode 100644 index 0000000..0f9f1e2 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift @@ -0,0 +1,59 @@ +// +// BattleScenarioDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleScenarioDataDTO { + func toDomain() -> BattleScenario { + BattleScenario( + battleId: battleId, + title: title, + philosophers: philosophers.map { $0.toDomain() }, + isInteractive: isInteractive, + startNodeId: startNodeId, + recommendedPathKey: RecommendedPathKey(rawValue: recommendedPathKey), + audios: audios, + nodes: nodes.map { $0.toDomain() } + ) + } +} + +public extension ScenarioPhilosopherDTO { + func toDomain() -> ScenarioPhilosopher { + ScenarioPhilosopher(label: label, name: name, stance: stance, imageUrl: imageUrl) + } +} + +public extension ScenarioNodeDTO { + func toDomain() -> ScenarioNode { + ScenarioNode( + nodeId: nodeId, + nodeName: nodeName, + audioDuration: audioDuration, + autoNextNodeId: autoNextNodeId, + scripts: scripts.map { $0.toDomain() }, + interactiveOptions: (interactiveOptions ?? []).map { $0.toDomain() } + ) + } +} + +public extension ScenarioScriptDTO { + func toDomain() -> ScenarioScript { + ScenarioScript( + scriptId: scriptId, + startTimeMs: startTimeMs, + speakerType: ScenarioSpeakerType(rawValue: speakerType), + speakerName: speakerName, + text: text + ) + } +} + +public extension ScenarioInteractiveOptionDTO { + func toDomain() -> ScenarioInteractiveOption { + ScenarioInteractiveOption(label: label, nextNodeId: nextNodeId) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 5b053e9..e98c3de 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -37,4 +37,18 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + + public func fetchScenario(battleId: Int) async throws -> BattleScenario { + let dto: BattleScenarioResponseDTO = try await provider.request( + .scenario(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "시나리오 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty scenario payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 2f7e85a..6b17891 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -12,6 +12,7 @@ import AsyncMoya public enum BattleService { case preVote(battleId: Int, body: PreVoteRequest) + case scenario(battleId: Int) } extension BattleService: BaseTargetType { @@ -23,6 +24,8 @@ extension BattleService: BaseTargetType { switch self { case let .preVote(battleId, _): BattleAPI.preVote(battleId: battleId).description + case let .scenario(battleId): + BattleAPI.scenario(battleId: battleId).description } } @@ -32,6 +35,8 @@ extension BattleService: BaseTargetType { switch self { case .preVote: .post + case .scenario: + .get } } @@ -39,6 +44,8 @@ extension BattleService: BaseTargetType { switch self { case let .preVote(_, body): body.toDictionary + case .scenario: + nil } } @@ -46,11 +53,3 @@ extension BattleService: BaseTargetType { APIHeader.baseHeader } } - -public struct PreVoteRequest: Encodable { - public let optionId: Int - - public init(optionId: Int) { - self.optionId = optionId - } -} diff --git a/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift b/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift index e69de29..dda3592 100644 --- a/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift +++ b/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift @@ -0,0 +1,16 @@ +// +// PreVoteRequest.swift +// Service +// +// Created by Wonji Suh on 5/21/26. +// + +import Foundation + +public struct PreVoteRequest: Encodable { + public let optionId: Int + + public init(optionId: Int) { + self.optionId = optionId + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 80897ec..5d3a531 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -26,11 +26,11 @@ public protocol AuthInterface: Sendable { /// Auth Repository 의 DependencyKey 구조체 public struct AuthRepositoryDependency: DependencyKey { public static var liveValue: AuthInterface { - UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() } public static var testValue: AuthInterface { - UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() } public static var previewValue: AuthInterface = liveValue diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 32ec97c..32a26e0 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -9,6 +9,7 @@ import WeaveDI public protocol BattleInterface: Sendable { func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult + func fetchScenario(battleId: Int) async throws -> BattleScenario } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index a535a23..7c04df1 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -12,4 +12,17 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } + + public func fetchScenario(battleId _: Int) async throws -> BattleScenario { + BattleScenario( + battleId: 0, + title: "", + philosophers: [], + isInteractive: false, + startNodeId: 0, + recommendedPathKey: .common, + audios: [:], + nodes: [] + ) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift index 348a97d..86f2d8c 100644 --- a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift @@ -17,11 +17,11 @@ public protocol GoogleOAuthProviderInterface: Sendable { /// Google OAuth Provider 의 DependencyKey 구조체 public struct GoogleOAuthProviderDependency: DependencyKey { public static var liveValue: GoogleOAuthProviderInterface { - UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() } public static var testValue: GoogleOAuthProviderInterface { - UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() } public static var previewValue: GoogleOAuthProviderInterface = testValue diff --git a/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift index 27e1a84..aba3cc9 100644 --- a/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift @@ -18,11 +18,11 @@ public protocol HomeInterface: Sendable { public struct HomeRepositoryDependency: DependencyKey { public static var liveValue: HomeInterface { - UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() } public static var testValue: HomeInterface { - UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() } public static var previewValue: HomeInterface = liveValue diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift index c892a39..0d0de7c 100644 --- a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift @@ -17,11 +17,11 @@ public protocol KakaoOAuthProviderInterface: Sendable { /// Kakao OAuth Provider의 DependencyKey 구조체 public struct KakaoOAuthProviderDependency: DependencyKey { public static var liveValue: KakaoOAuthProviderInterface { - UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() } public static var testValue: KakaoOAuthProviderInterface { - UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() } public static var previewValue: KakaoOAuthProviderInterface = testValue diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift index 4d29635..822fbc2 100644 --- a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift @@ -18,7 +18,7 @@ public protocol KakaoOAuthInterface: Sendable { public struct KakaoOAuthRepositoryDependencyKey: DependencyKey { public static var liveValue: KakaoOAuthInterface { - UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() + return UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() } public static var previewValue: KakaoOAuthInterface = MockKakaoOAuthRepository() diff --git a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift b/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift index 81df867..8b41e10 100644 --- a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift @@ -13,11 +13,11 @@ public protocol PollInterface: Sendable { public struct PollRepositoryDependency: DependencyKey { public static var liveValue: PollInterface { - UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() + return UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() } public static var testValue: PollInterface { - UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() + return UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() } public static var previewValue: PollInterface = liveValue diff --git a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift new file mode 100644 index 0000000..8d47945 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift @@ -0,0 +1,139 @@ +// +// BattleScenario.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/scenario` 응답 도메인 모델. +// + +import Foundation + +public struct BattleScenario: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let philosophers: [ScenarioPhilosopher] + public let isInteractive: Bool + public let startNodeId: Int + public let recommendedPathKey: RecommendedPathKey + public let audios: [String: String] + public let nodes: [ScenarioNode] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + philosophers: [ScenarioPhilosopher], + isInteractive: Bool, + startNodeId: Int, + recommendedPathKey: RecommendedPathKey, + audios: [String: String], + nodes: [ScenarioNode] + ) { + self.battleId = battleId + self.title = title + self.philosophers = philosophers + self.isInteractive = isInteractive + self.startNodeId = startNodeId + self.recommendedPathKey = recommendedPathKey + self.audios = audios + self.nodes = nodes + } +} + +public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String + + public var id: String { label } + + public init(label: String, name: String, stance: String, imageUrl: String) { + self.label = label + self.name = name + self.stance = stance + self.imageUrl = imageUrl + } +} + +public struct ScenarioNode: Equatable, Identifiable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScript] + public let interactiveOptions: [ScenarioInteractiveOption] + + public var id: Int { nodeId } + + public init( + nodeId: Int, + nodeName: String, + audioDuration: Int, + autoNextNodeId: Int?, + scripts: [ScenarioScript], + interactiveOptions: [ScenarioInteractiveOption] + ) { + self.nodeId = nodeId + self.nodeName = nodeName + self.audioDuration = audioDuration + self.autoNextNodeId = autoNextNodeId + self.scripts = scripts + self.interactiveOptions = interactiveOptions + } +} + +public struct ScenarioScript: Equatable, Identifiable, Hashable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: ScenarioSpeakerType + public let speakerName: String + public let text: String + + public var id: Int { scriptId } + + public init( + scriptId: Int, + startTimeMs: Int, + speakerType: ScenarioSpeakerType, + speakerName: String, + text: String + ) { + self.scriptId = scriptId + self.startTimeMs = startTimeMs + self.speakerType = speakerType + self.speakerName = speakerName + self.text = text + } +} + +public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let label: String + public let nextNodeId: Int + + public var id: String { "\(label)-\(nextNodeId)" } + + public init(label: String, nextNodeId: Int) { + self.label = label + self.nextNodeId = nextNodeId + } +} + +public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { + case narrator = "NARRATOR" + case philosopher = "PHILOSOPHER" + case unknown + + public init(rawValue: String) { + self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} + +public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { + case common = "COMMON" + case unknown + + public init(rawValue: String) { + self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index d650f47..d209ce9 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture +import DomainInterface import Entity import LogMacro @@ -18,14 +19,16 @@ public struct ChatRoomFeature { @ObservableState public struct State: Equatable { public var bundle: ChatRoomBundle = .mock + public var scenario: BattleScenario? public var isPlaying: Bool = false public var currentTime: TimeInterval = 0 public var battleId: Int = 0 + public var isLoadingScenario: Bool = false /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 public var hasFinishedListening: Bool = false public var totalDuration: TimeInterval { bundle.totalDuration } - public var battleTitle: String { bundle.battleTitle } + public var battleTitle: String { scenario?.title ?? bundle.battleTitle } public var messages: [ChatMessage] { bundle.messages } public var canScrub: Bool { hasFinishedListening } @@ -56,10 +59,12 @@ public struct ChatRoomFeature { public enum AsyncAction: Equatable { case startTicking case stopTicking + case fetchScenario } public enum InnerAction: Equatable { case tick + case scenarioResponse(Result) } public enum DelegateAction: Equatable { @@ -68,9 +73,11 @@ public struct ChatRoomFeature { nonisolated enum CancelID: Hashable { case tick + case fetchScenario } @Dependency(\.continuousClock) var clock + @Dependency(\.battleRepository) private var battleRepository public var body: some Reducer { BindingReducer() @@ -98,7 +105,8 @@ extension ChatRoomFeature { ) -> Effect { switch action { case .onAppear: - return .none + guard state.scenario == nil, !state.isLoadingScenario else { return .none } + return .send(.async(.fetchScenario)) case .backButtonTapped: return .send(.async(.stopTicking)).concatenate(with: .send(.delegate(.dismiss))) case .refreshTapped: @@ -123,10 +131,10 @@ extension ChatRoomFeature { } } - private func handleAsyncAction(state _: inout State, action: AsyncAction) -> Effect { + private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { switch action { case .startTicking: - .run { [clock] send in + return .run { [clock] send in for await _ in clock.timer(interval: .seconds(1)) { await send(.inner(.tick)) } @@ -134,7 +142,19 @@ extension ChatRoomFeature { .cancellable(id: CancelID.tick, cancelInFlight: true) case .stopTicking: - .cancel(id: CancelID.tick) + return .cancel(id: CancelID.tick) + + case .fetchScenario: + state.isLoadingScenario = true + let battleId = state.battleId + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchScenario(battleId: battleId) + } + .mapError(AuthError.from) + return await send(.inner(.scenarioResponse(result))) + } + .cancellable(id: CancelID.fetchScenario, cancelInFlight: true) } } @@ -150,6 +170,16 @@ extension ChatRoomFeature { } state.currentTime = next return .none + + case let .scenarioResponse(result): + state.isLoadingScenario = false + switch result { + case let .success(scenario): + state.scenario = scenario + case let .failure(error): + Log.error("[ChatRoomFeature] fetchScenario failed: \(error.localizedDescription)") + } + return .none } } diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift index f3719f5..6f8fc8c 100644 --- a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift @@ -208,7 +208,7 @@ extension PreVoteFeature { return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") - return .none + return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) } } } From 5bf3ca827fd2689ab8a37edfdf43f56a64dcd4fa Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 01:29:38 +0900 Subject: [PATCH 07/34] =?UTF-8?q?docs:=20switch=20=EA=B8=B0=EB=B0=98=20com?= =?UTF-8?q?puted=20property=20return=20=EB=AA=85=EC=8B=9C=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=ED=99=95=EC=9E=A5=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DomainType.url 한정이던 기존 규칙을 BaseTargetType (urlPath / method / parameters / task / sampleData) 와 DependencyKey (liveValue / testValue) 까지 일반화 - 새 case 추가 시 기존 case 까지 explicit return 으로 정렬하라는 가이드 명시 - 포맷터가 반복 깨면 swiftformat:disable redundantReturn 페어 사용 권고 --- AGENTS.md | 80 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0bb9e4..5f6abc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -509,43 +509,81 @@ public extension HomeCoordinator { - 라우터 핸들러 (`routerAction`) 안에서 `state.routes.push/pop/goBack` 직접 호출은 OK, 단 `dismiss`/`submit` 같이 반복되는 종료 액션은 `.send(.view(.backAction))` 으로 일원화 - 레퍼런스: `HomeCoordinator`, `AuthCoordinator`, `MainTabCoordinator` -#### 🌐 DomainType `url` switch — 모든 case 에 `return` 명시 유지 +#### 🌐 switch 기반 computed property — 모든 case 에 `return` 명시 유지 (DomainType / BaseTargetType / DependencyKey 공통) -`PieckeDomain` 같은 `DomainType` 의 `url: String` computed property 는 **모든 case 에 `return` 키워드를 명시한다**. 자동 포맷터가 single-expression switch 규칙으로 `return` 을 떼어내려고 하지만, 새 case 추가 시 컴파일 에러 메시지가 끊기고 가독성도 망가지므로 **수동으로라도 되돌려야** 한다. +`switch self { ... }` 만으로 값을 돌려주는 computed property 는 **모든 case 에 `return` 키워드를 명시한다**. 적용 범위: + +1. **`DomainType.url`** — `PieckeDomain` 같은 도메인 prefix 매핑 +2. **`BaseTargetType` 구현체의 `urlPath` / `method` / `parameters` / `task` / `sampleData`** — 모든 Moya TargetType switch +3. **`DependencyKey.liveValue` / `testValue`** — `UnifiedDI.resolve(...) ?? Default...()` 패턴 +4. 그 외 단일 표현식 switch 를 본문으로 갖는 computed property 전부 + +자동 포맷터의 `redundantReturn` 룰이 single-expression switch 에서 `return` 을 떼어내려 하지만, **새 case 추가 시 일부만 implicit / 일부는 explicit 으로 혼합되는 상태가 가장 깨지기 쉽다**. 새 case 추가 후엔 항상 기존 case 까지 같이 `return` 으로 정렬할 것. ```swift -// ✅ 올바른 패턴 — 모든 case 에 return 명시 +// ✅ DomainType — 모든 case return extension PieckeDomain: DomainType { public var url: String { switch self { - case .auth: - return "api/v1/auth/" - case .profile: - return "api/v1/me/" - case .home: - return "api/v1/home" - case .poll: - return "api/v1/poll" - case .battle: - return "api/v1/battles/" + case .auth: return "api/v1/auth/" + case .profile: return "api/v1/me/" + case .home: return "api/v1/home" + case .poll: return "api/v1/poll" + case .battle: return "api/v1/battles/" + } + } +} + +// ✅ BaseTargetType — urlPath / method / parameters 모두 return 명시 +extension BattleService: BaseTargetType { + public var urlPath: String { + switch self { + case let .preVote(battleId, _): + return BattleAPI.preVote(battleId: battleId).description + case let .scenario(battleId): + return BattleAPI.scenario(battleId: battleId).description } } + + public var method: Moya.Method { + switch self { + case .preVote: return .post + case .scenario: return .get + } + } + + public var parameters: [String: Any]? { + switch self { + case let .preVote(_, body): return body.toDictionary + case .scenario: return nil + } + } +} + +// ✅ DependencyKey — liveValue / testValue 도 return 명시 +public struct BattleRepositoryDependency: DependencyKey { + public static var liveValue: BattleInterface { + return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + public static var testValue: BattleInterface { + return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } } -// ❌ 금지 — 포맷터가 떼어낸 implicit return (혼합 상태) -public var url: String { +// ❌ 금지 — 포맷터가 떼어낸 implicit return 혼합 +public var method: Moya.Method { switch self { - case .auth: "api/v1/auth/" // ← 안 됨 - case .poll: return "api/v1/poll" // ← 안 됨 (혼합) + case .preVote: .post // ← 안 됨 + case .scenario: return .get // ← 혼합 상태 } } ``` 규칙: -- 새 case 를 추가했는데 포맷터가 기존 case 의 `return` 을 떼어냈다면 **PR 전에 직접 되돌려서 일관성 유지** -- 새 도메인 case (`.battle` 등) 도 동일하게 `return "..."` 형태로 작성 -- 포맷터의 `redundantReturn` 룰이 자꾸 깨면 해당 파일에 `// swiftformat:disable redundantReturn` 디렉티브 페어 추가 검토 -- 레퍼런스: `Projects/Data/API/Sources/Base/PieckeDomain.swift` +- 새 case 추가 후 포맷터가 기존 case 의 `return` 을 떼어냈다면 **PR 전에 직접 되돌려서 일관성 유지** +- 새 도메인 case (`.battle` 등) / 새 서비스 case (`.scenario` 등) / 새 DI 키 추가 시 모두 동일하게 `return ...` 형태로 작성 +- 포맷터가 반복적으로 깨면 해당 파일 또는 함수 블록에 `// swiftformat:disable redundantReturn` 디렉티브 페어 추가 검토 +- 레퍼런스: `PieckeDomain`, `BattleService`, `AuthService`, `BattleRepositoryDependency`, `HomeRepositoryDependency` ### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`) - Swift 스타일 가이드 From 8be355b09c283c7ebdfc061f221a7e8376f976d3 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 01:34:52 +0900 Subject: [PATCH 08/34] =?UTF-8?q?fix:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=203=EB=B2=84=ED=8A=BC=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=20baseline=20=EC=A0=95=EB=A0=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HStack alignment .top + 모든 버튼이 동일한 VStack(Image + Caption) 구조 - play 버튼은 caption=nil 로 자리만 차지(opacity 0) → backward/forward 의 "15초" 레이블과 위치 어긋남 해소 - Image frame 을 40x32 로 고정해 SF Symbol 너비 차이로 인한 좌우 비대칭 제거 --- .../AudioPlayer/AudioPlayerControlView.swift | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift index 2e3d3e2..1fd087f 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift @@ -27,7 +27,7 @@ public struct AudioPlayerControlView: View { } public var body: some View { - HStack(spacing: 32) { + HStack(alignment: .top, spacing: 32) { backwardButton() playButton() forwardButton() @@ -38,15 +38,11 @@ public struct AudioPlayerControlView: View { @ViewBuilder private func backwardButton() -> some View { Button(action: onBackward) { - VStack(spacing: 4) { - Image(systemName: "backward.end.fill") - .font(.system(size: 28)) - .foregroundStyle(.primary800) - Text("15초") - .pretendardFont(family: .Medium, size: 11) - .foregroundStyle(.neutral300) - } - .frame(width: 55, height: 55) + controlColumn( + systemImage: "backward.end.fill", + iconColor: .primary800, + caption: "15초" + ) } .buttonStyle(.plain) } @@ -54,10 +50,11 @@ public struct AudioPlayerControlView: View { @ViewBuilder private func playButton() -> some View { Button(action: onTogglePlay) { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 28)) - .foregroundStyle(.neutral900) - .frame(width: 55, height: 55) + controlColumn( + systemImage: isPlaying ? "pause.fill" : "play.fill", + iconColor: .neutral900, + caption: nil + ) } .buttonStyle(.plain) } @@ -65,16 +62,33 @@ public struct AudioPlayerControlView: View { @ViewBuilder private func forwardButton() -> some View { Button(action: onForward) { - VStack(spacing: 4) { - Image(systemName: "forward.end.fill") - .font(.system(size: 28)) - .foregroundStyle(.primary800) - Text("15초") - .pretendardFont(family: .Medium, size: 11) - .foregroundStyle(.neutral300) - } - .frame(width: 55, height: 55) + controlColumn( + systemImage: "forward.end.fill", + iconColor: .primary800, + caption: "15초" + ) } .buttonStyle(.plain) } + + /// 세 버튼이 동일한 baseline 으로 정렬되도록 VStack 구조 + caption 자리를 항상 확보한다. + @ViewBuilder + private func controlColumn( + systemImage: String, + iconColor: Color, + caption: String? + ) -> some View { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 28)) + .foregroundStyle(iconColor) + .frame(width: 40, height: 32) + + Text(caption ?? " ") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + .opacity(caption == nil ? 0 : 1) + } + .frame(width: 55) + } } From e05301b1fb3b9784960a17d083c0a6514dc7c038 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 02:21:25 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20=EC=99=84=EC=B2=AD=20=ED=9B=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=A7=80=20=EC=84=B9=EC=85=98=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?(.pen=20=EC=B1=84=ED=8C=85=EB=B0=A9=5F=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A7=80)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomFeature - State.currentNodeId / selectedOptionLabel / interactiveOptions / shouldShowOptions / isConfirmEnabled 추가 - scenarioResponse 시 startNodeId 로 currentNodeId 초기화 - View.optionTapped(label) / confirmOptionTapped 추가 - 확정 시 currentNodeId 를 nextNodeId 로 전환 + currentTime / hasFinishedListening 리셋해 다음 노드 청취 사이클 진입 - ChatRoomView.interactiveOptionsSection - shouldShowOptions == true 일 때만 메시지 리스트와 재생바 사이에 노출 - 헤더 (가는 선 + "당신의 입장을 선택해주세요" + 가는 선) - 선택지 카드 — 선택 시 stroke #E1B974, 텍스트 neutral800 - "입장 선택하기" Primary CTA, 선택 전엔 비활성 --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 40 ++++++++++ .../Sources/ChatRoom/View/ChatRoomView.swift | 78 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index d209ce9..543785a 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -27,11 +27,33 @@ public struct ChatRoomFeature { /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 public var hasFinishedListening: Bool = false + /// 현재 재생 중인 시나리오 노드 id (없으면 startNodeId 폴백) + public var currentNodeId: Int? + /// 선택지 영역에서 사용자가 탭한 옵션 label + public var selectedOptionLabel: String? + public var totalDuration: TimeInterval { bundle.totalDuration } public var battleTitle: String { scenario?.title ?? bundle.battleTitle } public var messages: [ChatMessage] { bundle.messages } public var canScrub: Bool { hasFinishedListening } + public var currentNode: ScenarioNode? { + guard let scenario else { return nil } + let target = currentNodeId ?? scenario.startNodeId + return scenario.nodes.first { $0.nodeId == target } + } + + public var interactiveOptions: [ScenarioInteractiveOption] { + currentNode?.interactiveOptions ?? [] + } + + /// 현재 노드에 선택지가 있고, 한 번 끝까지 들었으면 선택 카드 노출 + public var shouldShowOptions: Bool { + hasFinishedListening && !interactiveOptions.isEmpty + } + + public var isConfirmEnabled: Bool { selectedOptionLabel != nil } + public init(battleId: Int = 0) { self.battleId = battleId } @@ -54,6 +76,8 @@ public struct ChatRoomFeature { case seekBackwardTapped case seekForwardTapped case scrub(TimeInterval) + case optionTapped(String) + case confirmOptionTapped } public enum AsyncAction: Equatable { @@ -128,6 +152,19 @@ extension ChatRoomFeature { guard state.canScrub else { return .none } state.currentTime = min(max(0, time), state.totalDuration) return .none + case let .optionTapped(label): + state.selectedOptionLabel = (state.selectedOptionLabel == label) ? nil : label + return .none + case .confirmOptionTapped: + guard let label = state.selectedOptionLabel, + let option = state.interactiveOptions.first(where: { $0.label == label }) + else { return .none } + state.currentNodeId = option.nextNodeId + state.selectedOptionLabel = nil + state.currentTime = 0 + state.hasFinishedListening = false + state.isPlaying = false + return .send(.async(.stopTicking)) } } @@ -176,6 +213,9 @@ extension ChatRoomFeature { switch result { case let .success(scenario): state.scenario = scenario + if state.currentNodeId == nil { + state.currentNodeId = scenario.startNodeId + } case let .failure(error): Log.error("[ChatRoomFeature] fetchScenario failed: \(error.localizedDescription)") } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 7a0881d..8ce1ead 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -25,6 +25,9 @@ public struct ChatRoomView: View { VStack(spacing: 0) { navigationBar() messageList() + if store.shouldShowOptions { + interactiveOptionsSection() + } playerBar() } .background(Color.beige200.ignoresSafeArea()) @@ -163,6 +166,81 @@ extension ChatRoomView { } } +// MARK: - Interactive Options + +extension ChatRoomView { + @ViewBuilder + private func interactiveOptionsSection() -> some View { + VStack(spacing: 12) { + optionsHeader() + optionsList() + confirmButton() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.beige100) + } + + @ViewBuilder + private func optionsHeader() -> some View { + HStack(spacing: 10) { + Rectangle() + .fill(.neutral200) + .frame(height: 0.5) + Text("당신의 입장을 선택해주세요") + .pretendardFont(family: .Bold, size: 13) + .foregroundStyle(.neutral800) + .fixedSize() + Rectangle() + .fill(.neutral200) + .frame(height: 0.5) + } + } + + @ViewBuilder + private func optionsList() -> some View { + VStack(spacing: 9) { + ForEach(store.interactiveOptions, id: \.label) { option in + optionCard(option) + } + } + } + + @ViewBuilder + private func optionCard(_ option: ScenarioInteractiveOption) -> some View { + let isSelected = store.selectedOptionLabel == option.label + + Button { + send(.optionTapped(option.label)) + } label: { + Text(option.label) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(isSelected ? .neutral800 : .neutral300) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? Color(hex: "E1B974") : .beige600, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func confirmButton() -> some View { + CustomButton( + action: { send(.confirmOptionTapped) }, + title: "입장 선택하기", + config: CustomButtonConfig.primary(.large, height: 42), + isEnable: store.isConfirmEnabled + ) + } +} + // MARK: - Player Bar extension ChatRoomView { From 625e65dde93fab0f8ef0d3ff185f5109b6671a30 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 02:23:11 +0900 Subject: [PATCH 10/34] =?UTF-8?q?chore:=20=EC=84=A0=ED=83=9D=EC=A7=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=B3=B4=EB=8D=94=EB=A5=BC=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20+=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=ED=95=AD=EC=83=81=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomView 옵션 카드 stroke - hardcoded "E1B974" / .beige600 → .borderSecondarySelected / .borderBeigeDefault - 배경도 .beige400 (디자인 #f7f4ee) 로 정정 - ChatRoomFeature.canScrub 를 임시 true 강제 - 음원 실재생 연결 전까지 진행바 드래그/탭 시킹 항상 가능 - 실재생 연동 후 hasFinishedListening 게이트로 복원 (TODO 코멘트) --- .../Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift | 3 ++- .../Home/Sources/ChatRoom/View/ChatRoomView.swift | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 543785a..cc60400 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -35,7 +35,8 @@ public struct ChatRoomFeature { public var totalDuration: TimeInterval { bundle.totalDuration } public var battleTitle: String { scenario?.title ?? bundle.battleTitle } public var messages: [ChatMessage] { bundle.messages } - public var canScrub: Bool { hasFinishedListening } + // TODO: 음원 실재생 연결 후 hasFinishedListening 게이트로 복원 + public var canScrub: Bool { true } public var currentNode: ScenarioNode? { guard let scenario else { return nil } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 8ce1ead..937dfb3 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -221,10 +221,10 @@ extension ChatRoomView { .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 20) .padding(.vertical, 16) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(isSelected ? Color(hex: "E1B974") : .beige600, lineWidth: 1) + .stroke(isSelected ? .borderSecondarySelected : .borderBeigeDefault, lineWidth: 1) ) } .buttonStyle(.plain) From 09803f0bd75fd782d8c1b59aea1d664725c214d1 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 02:28:14 +0900 Subject: [PATCH 11/34] =?UTF-8?q?chore:=20=EC=84=A0=ED=83=9D=EC=A7=80=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=9E=84=EC=8B=9C=20=EC=99=84?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=EC=98=B5=EC=85=98=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=ED=95=AD=EC=83=81=20=EB=85=B8=EC=B6=9C=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomFeature.shouldShowOptions 에서 hasFinishedListening 조건 제거 - currentNode 의 interactiveOptions 가 있으면 즉시 노출 - 음원 실재생 연동 후 완청 시점 게이트로 복원 예정 --- .../Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index cc60400..7746910 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -48,9 +48,9 @@ public struct ChatRoomFeature { currentNode?.interactiveOptions ?? [] } - /// 현재 노드에 선택지가 있고, 한 번 끝까지 들었으면 선택 카드 노출 + /// 현재 노드에 선택지가 있으면 선택 카드 노출 (실재생 연동 전 임시 — 항상 노출) public var shouldShowOptions: Bool { - hasFinishedListening && !interactiveOptions.isEmpty + !interactiveOptions.isEmpty } public var isConfirmEnabled: Bool { selectedOptionLabel != nil } From e1d93d2d4ea24d75a470f85599d412a3ed04b0cc Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 03:24:50 +0900 Subject: [PATCH 12/34] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20body=20JSON=20=EB=94=94=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthRepositoryImpl.login 진입 직전 JSONEncoder 로 body 직렬화 후 Log.debug - provider 별 페이로드 차이(identityToken / redirectUri 포함 여부) 콘솔에서 즉시 확인 --- .../Data/API/Sources/Battle/BattleAPI.swift | 4 ++-- .../Auth/Repository/AuthRepositoryImpl.swift | 20 +++++++++++-------- .../Service/Sources/Auth/AuthService.swift | 12 +++++------ .../Sources/Battle/BattleService.swift | 14 ++++++------- .../Service/Sources/Home/HomeService.swift | 6 +++--- .../Service/Sources/Poll/PollService.swift | 4 ++-- .../Manager/KeychainManagerInterface.swift | 4 ++-- 7 files changed, 34 insertions(+), 30 deletions(-) diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 8c5cc3e..21319fd 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -12,9 +12,9 @@ public enum BattleAPI { public var description: String { switch self { case let .preVote(battleId): - "\(battleId)/votes/pre" + return "\(battleId)/votes/pre" case let .scenario(battleId): - "\(battleId)/scenario" + return "\(battleId)/scenario" } } } diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index 3603724..5897a0a 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -41,15 +41,19 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { redirectUri: String?, idToken: String? ) async throws -> LoginEntity { + let body = OAuthLoginRequest( + authorizationCode: authorizationCode, + redirectUri: redirectUri, + idToken: idToken + ) + if let data = try? JSONEncoder().encode(body), + let json = String(data: data, encoding: .utf8) + { + Log.debug("[AuthRepository] POST /api/v1/auth/login/\(socialProvider.rawValue) body=\(json)") + } + let dto: LoginResponseDTO = try await provider.request( - .login( - provider: socialProvider, - body: OAuthLoginRequest( - authorizationCode: authorizationCode, - redirectUri: redirectUri, - idToken: idToken - ) - ) + .login(provider: socialProvider, body: body) ) guard let data = dto.data else { diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index ba5d628..569aa4b 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -52,22 +52,22 @@ extension AuthService: BaseTargetType { public var method: Moya.Method { switch self { case .login, .refresh, .logout: - .post + return .post case .withdraw: - .delete + return .delete } } public var parameters: [String: Any]? { switch self { case let .login(_, body): - body.toDictionary + return body.toDictionary case .refresh: - nil + return nil case let .withdraw(token): - token.toDictionary(key: "token") + return token.toDictionary(key: "token") case .logout: - nil + return nil } } diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 6b17891..856c45e 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -23,9 +23,9 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { case let .preVote(battleId, _): - BattleAPI.preVote(battleId: battleId).description + return BattleAPI.preVote(battleId: battleId).description case let .scenario(battleId): - BattleAPI.scenario(battleId: battleId).description + return BattleAPI.scenario(battleId: battleId).description } } @@ -34,22 +34,22 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { case .preVote: - .post + return .post case .scenario: - .get + return .get } } public var parameters: [String: Any]? { switch self { case let .preVote(_, body): - body.toDictionary + return body.toDictionary case .scenario: - nil + return nil } } public var headers: [String: String]? { - APIHeader.baseHeader + return APIHeader.baseHeader } } diff --git a/Projects/Data/Service/Sources/Home/HomeService.swift b/Projects/Data/Service/Sources/Home/HomeService.swift index c34ac4f..574205f 100644 --- a/Projects/Data/Service/Sources/Home/HomeService.swift +++ b/Projects/Data/Service/Sources/Home/HomeService.swift @@ -24,7 +24,7 @@ extension HomeService: BaseTargetType { public var urlPath: String { switch self { case .home: - HomeAPI.home.description + return HomeAPI.home.description } } @@ -33,13 +33,13 @@ extension HomeService: BaseTargetType { public var method: Moya.Method { switch self { case .home: - .get + return .get } } public var parameters: [String: Any]? { nil } public var headers: [String: String]? { - APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) + return APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) } } diff --git a/Projects/Data/Service/Sources/Poll/PollService.swift b/Projects/Data/Service/Sources/Poll/PollService.swift index d77d8f0..ee5bd20 100644 --- a/Projects/Data/Service/Sources/Poll/PollService.swift +++ b/Projects/Data/Service/Sources/Poll/PollService.swift @@ -33,13 +33,13 @@ extension PollService: BaseTargetType { public var method: Moya.Method { switch self { case .detailPoll: - .get + return .get } } public var parameters: [String: Any]? { nil } public var headers: [String: String]? { - APIHeader.baseHeader + return APIHeader.baseHeader } } diff --git a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift index 8488e42..ffefaff 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift @@ -20,11 +20,11 @@ public protocol KeychainManaging: Sendable { public struct KeychainManagerDependency: DependencyKey { public static var liveValue: KeychainManaging { - UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() + return UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() } public static var testValue: KeychainManaging { - InMemoryKeychainManager() + return InMemoryKeychainManager() } public static var previewValue: KeychainManaging = testValue From 7c0b9c7eeccaf0a62900ed04962f824fae021f6d Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 03:47:16 +0900 Subject: [PATCH 13/34] =?UTF-8?q?style:=20=ED=99=88=20=EC=B9=B4=EB=A1=9C?= =?UTF-8?q?=EC=85=80=20=EC=B9=B4=EB=93=9C=20=ED=83=9C=EA=B7=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20#=20=EC=A0=91=EB=91=90=EC=82=AC=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=20=EC=A0=81=EC=9A=A9=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HotBattleCardView / HeroCarouselView / BestBattleCardView / NewBattleCardView - Text(tag.name) → Text("#\(tag.name)") --- .../Home/Sources/Main/View/Components/BestBattleCardView.swift | 2 +- .../Home/Sources/Main/View/Components/HeroCarouselView.swift | 2 +- .../Home/Sources/Main/View/Components/HotBattleCardView.swift | 2 +- .../Home/Sources/Main/View/Components/NewBattleCardView.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift index efd0e7c..6c7edfc 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -31,7 +31,7 @@ struct BestBattleCardView: View { .lineLimit(2) HStack(spacing: 8) { ForEach(battle.tags) { tag in - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral300) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift index 7ebf9b8..2dc7e93 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -157,7 +157,7 @@ struct HeroCardView: View { HStack(spacing: 4) { ForEach(hero.tags) { tag in - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral200) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift index 30c78ec..4093ccf 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -21,7 +21,7 @@ struct HotBattleCardView: View { thumbnail VStack(alignment: .leading, spacing: 6) { if let tag = battle.tags.first { - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.primary500) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift index d7e10fc..3220d34 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -51,7 +51,7 @@ extension NewBattleCardView { private var metaRow: some View { HStack(spacing: 10) { if let tag = battle.tags.first { - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.primary500) .padding(.horizontal, 6) From eb301eaed87a466439139d8573750a4f0e5283f6 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 03:50:02 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat:=20=EC=A7=80=EA=B8=88=20=EB=9C=A8?= =?UTF-8?q?=EB=8A=94=20=EB=B0=B0=ED=8B=80=20=EC=84=B9=EC=85=98=20=EB=B9=88?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=9D=BC=20=EB=95=8C=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=EC=B2=98=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeBundle.replacingEmptySectionsWithMocks 의 hotBattles fallback 제거 (API trendingBattles=[] 일 때 mock 으로 치환하지 않고 빈 배열 유지) - HomeView 의 hotBattlesSection 호출부에 store.hotBattles.isEmpty 가드 추가 --- Projects/Domain/Entity/Sources/Home/HomeBundle.swift | 2 +- Projects/Presentation/Home/Sources/Main/View/HomeView.swift | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift index eb47860..3f8644f 100644 --- a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift +++ b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift @@ -51,7 +51,7 @@ public extension HomeBundle { HomeBundle( newNotice: newNotice, heroes: heroes.isEmpty ? HeroBattle.mocks : heroes, - hotBattles: hotBattles.isEmpty ? HotBattle.mocks : hotBattles, + hotBattles: hotBattles, bestBattles: bestBattles.isEmpty ? BestBattle.mocks : bestBattles, quizzes: quizzes.isEmpty ? [.mock] : quizzes, votes: votes.isEmpty ? [.mock] : votes, diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 2aea739..7dd9e91 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -35,7 +35,9 @@ public struct HomeView: View { onTap: { send(.heroTapped($0)) } ) - hotBattlesSection() + if !store.hotBattles.isEmpty { + hotBattlesSection() + } bestBattlesSection() todayPickeSection() newBattlesSection() @@ -114,7 +116,7 @@ extension HomeView { if let vote = store.currentVote { VoteCardView(question: vote) .contentShape(Rectangle()) - .onTapGesture { } + .onTapGesture {} } } .padding(.horizontal, 16) From 820286736fd0946fc6fdbcd3026297d977ceba1a Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 03:51:28 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=84=B9=EC=85=98=20=EB=B9=88=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=9D=BC=20=EB=95=8C=20=EC=88=A8=EA=B9=80=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=20=EC=A0=81=EC=9A=A9=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeBundle.replacingEmptySectionsWithMocks 를 self 반환으로 단순화 (mock 치환 제거 — 서버 응답 그대로 노출) - HomeView 각 섹션 호출부에 isEmpty 가드 - heroes / hotBattles / bestBattles / newBattles - todayPicke 는 quizzes / votes 둘 다 비어 있을 때 숨김 --- .../Entity/Sources/Home/HomeBundle.swift | 13 ++-------- .../Home/Sources/Main/View/HomeView.swift | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift index 3f8644f..db73bd0 100644 --- a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift +++ b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift @@ -47,15 +47,6 @@ public extension HomeBundle { newBattles: NewBattle.mocks ) - var replacingEmptySectionsWithMocks: HomeBundle { - HomeBundle( - newNotice: newNotice, - heroes: heroes.isEmpty ? HeroBattle.mocks : heroes, - hotBattles: hotBattles, - bestBattles: bestBattles.isEmpty ? BestBattle.mocks : bestBattles, - quizzes: quizzes.isEmpty ? [.mock] : quizzes, - votes: votes.isEmpty ? [.mock] : votes, - newBattles: newBattles.isEmpty ? NewBattle.mocks : newBattles - ) - } + /// 서버 응답을 그대로 노출 — 빈 섹션은 UI 에서 숨김. + var replacingEmptySectionsWithMocks: HomeBundle { self } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 7dd9e91..366fa64 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -29,18 +29,26 @@ public struct HomeView: View { HomeSkeletonView() } else { VStack(spacing: 32) { - HeroCarouselView( - heroes: store.heroes, - currentIndex: $store.heroIndex, - onTap: { send(.heroTapped($0)) } - ) + if !store.heroes.isEmpty { + HeroCarouselView( + heroes: store.heroes, + currentIndex: $store.heroIndex, + onTap: { send(.heroTapped($0)) } + ) + } if !store.hotBattles.isEmpty { hotBattlesSection() } - bestBattlesSection() - todayPickeSection() - newBattlesSection() + if !store.bestBattles.isEmpty { + bestBattlesSection() + } + if !store.quizzes.isEmpty || !store.votes.isEmpty { + todayPickeSection() + } + if !store.newBattles.isEmpty { + newBattlesSection() + } } .padding(.bottom, 24) } From 2c3b5c49f258aa4eeb38c962b15990edc4bab4fc Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:01:55 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20API(/api/v1/battles/{id})=20=EC=97=B0=EB=8F=99=20+?= =?UTF-8?q?=20Poll=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity 신설 - BattleDetail / BattleInfo / BattleOption / UserVoteStatus / BattleStep - 기존 BattleTag.TagType 에 case value = "VALUE" 추가 - Data 신설 - BattleDetailDataDTO + 매퍼 (battleInfo / option / tag) - BattleAPI.detail, BattleService.detail (GET, no body) - DomainInterface - BattleInterface.fetchBattle(battleId:) + Default/Repository 구현 - PreVoteFeature - PollDetail / pollRepository / pollId 의존 전부 제거 - State.init(battleId:) 외부 주입 필수, fetchBattleDetail 으로 갱신 - shareTapped 의 URL 은 응답 shareUrl 우선 - makeBattle 을 BattleDetail.options + representative 기반으로 재작성 - categoryTags 는 화면에서 "#태그" 형태로 그대로 매핑 - HomeCoordinator: presentPreVote(battleId) 델리게이트에서 battleId 를 .preVote(.init(battleId:)) 로 전파 - DI: PollRepositoryImpl 등록 제거 - 삭제: Data/API/Poll, Data/Model/Poll, Data/Repository/Poll, Data/Service/Poll, DomainInterface/Poll, Entity/Poll 디렉터리 전체 - PreVoteView.shouldShowSkeleton: store.poll → store.battleDetail --- Projects/App/Sources/Di/DiRegister.swift | 1 - .../Data/API/Sources/Battle/BattleAPI.swift | 7 +- Projects/Data/API/Sources/Poll/PollAPI.swift | 19 --- .../Battle/DTO/BattleDetailDataDTO.swift | 47 +++++++ .../Battle/Mapper/BattleDetailDataDTO+.swift | 62 +++++++++ .../Model/Sources/Poll/DTO/PollDataDTO.swift | 29 ---- .../Sources/Poll/Mapper/PollDataDTO+.swift | 43 ------ .../Sources/Battle/BattleRepositoryImpl.swift | 14 ++ .../Sources/Poll/PollRepositoryImpl.swift | 38 ----- .../Sources/Battle/BattleService.swift | 21 ++- .../Service/Sources/Poll/PollService.swift | 45 ------ .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 23 +++ .../Poll/DefaultPollRepositoryImpl.swift | 15 -- .../Sources/Poll/PollInterface.swift | 31 ----- .../Entity/Sources/Home/BattleDetail.swift | 131 ++++++++++++++++++ .../Entity/Sources/Home/BattleTag.swift | 2 + .../Entity/Sources/Poll/PollDetail.swift | 97 ------------- .../Coordinator/Reducer/HomeCoordinator.swift | 4 +- .../Sources/Vote/Reducer/PreVoteFeature.swift | 84 +++++------ .../Home/Sources/Vote/View/PreVoteView.swift | 2 +- 21 files changed, 345 insertions(+), 371 deletions(-) delete mode 100644 Projects/Data/API/Sources/Poll/PollAPI.swift create mode 100644 Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift delete mode 100644 Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift delete mode 100644 Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift delete mode 100644 Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift delete mode 100644 Projects/Data/Service/Sources/Poll/PollService.swift delete mode 100644 Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift delete mode 100644 Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Home/BattleDetail.swift delete mode 100644 Projects/Domain/Entity/Sources/Poll/PollDetail.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index bde7187..0aa5efd 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -36,7 +36,6 @@ public final class AppDIManager: Sendable { // 🏗️ Repository 계층 (Clean Architecture + PFW) .register { AuthRepositoryImpl() as AuthInterface } .register { HomeRepositoryImpl() as HomeInterface } - .register { PollRepositoryImpl() as PollInterface } .register { BattleRepositoryImpl() as BattleInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 21319fd..35d34d5 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -6,15 +6,18 @@ import Foundation public enum BattleAPI { + case detail(battleId: Int) case preVote(battleId: Int) case scenario(battleId: Int) public var description: String { switch self { + case let .detail(battleId): + "\(battleId)" case let .preVote(battleId): - return "\(battleId)/votes/pre" + "\(battleId)/votes/pre" case let .scenario(battleId): - return "\(battleId)/scenario" + "\(battleId)/scenario" } } } diff --git a/Projects/Data/API/Sources/Poll/PollAPI.swift b/Projects/Data/API/Sources/Poll/PollAPI.swift deleted file mode 100644 index 1ae62d7..0000000 --- a/Projects/Data/API/Sources/Poll/PollAPI.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// PollAPI.swift -// API -// -// Created by Wonji Suh on 5/19/26. -// - -import Foundation - -public enum PollAPI { - case detailPoll(pollId: Int) - - public var description: String { - switch self { - case .detailPoll(let pollId): - return "/\(pollId)" - } - } -} diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift new file mode 100644 index 0000000..b29694c --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -0,0 +1,47 @@ +// +// BattleDetailDataDTO.swift +// Model +// + +import Foundation + +public struct BattleDetailDataDTO: Decodable { + public let battleInfo: BattleInfoDTO + public let description: String + public let shareUrl: String + public let userVoteStatus: String + public let currentStep: String + public let categoryTags: [BattleTagDTO] + public let philosopherTags: [BattleTagDTO] + public let valueTags: [BattleTagDTO] +} + +public struct BattleInfoDTO: Decodable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTagDTO] + public let options: [BattleOptionDTO] +} + +public struct BattleOptionDTO: Decodable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTagDTO] +} + +public struct BattleTagDTO: Decodable { + public let tagId: Int + public let name: String + public let type: String +} + +public typealias BattleDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift new file mode 100644 index 0000000..31d37a5 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -0,0 +1,62 @@ +// +// BattleDetailDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleDetailDataDTO { + func toDomain() -> BattleDetail { + BattleDetail( + battleInfo: battleInfo.toDomain(), + description: description, + shareUrl: shareUrl, + userVoteStatus: UserVoteStatus(rawValue: userVoteStatus), + currentStep: BattleStep(rawValue: currentStep), + categoryTags: categoryTags.map { $0.toDomain() }, + philosopherTags: philosopherTags.map { $0.toDomain() }, + valueTags: valueTags.map { $0.toDomain() } + ) + } +} + +public extension BattleInfoDTO { + func toDomain() -> BattleInfo { + BattleInfo( + battleId: battleId, + title: title, + summary: summary, + thumbnailUrl: thumbnailUrl, + viewCount: viewCount, + participantsCount: participantsCount, + audioDuration: audioDuration, + tags: tags.map { $0.toDomain() }, + options: options.map { $0.toDomain() } + ) + } +} + +public extension BattleOptionDTO { + func toDomain() -> BattleOption { + BattleOption( + optionId: optionId, + label: label, + title: title, + stance: stance, + representative: representative, + imageUrl: imageUrl, + tags: tags.map { $0.toDomain() } + ) + } +} + +public extension BattleTagDTO { + func toDomain() -> BattleTag { + BattleTag( + tagId: tagId, + name: name, + type: TagType(rawValue: type) + ) + } +} diff --git a/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift b/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift deleted file mode 100644 index cb55823..0000000 --- a/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// PollDataDTO.swift -// Model -// -// `GET /api/v1/polls/{pollId}` 의 data 페이로드. -// - -import Foundation - -public struct PollDataDTO: Decodable { - public let pollId: Int - public let titlePrefix: String - public let titleSuffix: String - public let targetDate: String? - public let status: String - public let options: [PollOptionDTO] -} - -public struct PollOptionDTO: Decodable, Identifiable { - public let optionId: Int - public let label: String - public let title: String - public let displayOrder: Int - public let voteCount: Int - - public var id: Int { optionId } -} - -public typealias PollResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift b/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift deleted file mode 100644 index a4c1e0f..0000000 --- a/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// PollDataDTO+.swift -// Model -// - -import Entity -import Foundation - -private let pollDateFormatter: DateFormatter = { - let f = DateFormatter() - f.calendar = Calendar(identifier: .gregorian) - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(secondsFromGMT: 0) - f.dateFormat = "yyyy-MM-dd" - return f -}() - -public extension PollOptionDTO { - func toDomain() -> PollOption { - PollOption( - optionId: optionId, - label: label, - title: title, - displayOrder: displayOrder, - voteCount: voteCount - ) - } -} - -public extension PollDataDTO { - func toDomain() -> PollDetail { - PollDetail( - pollId: pollId, - titlePrefix: titlePrefix, - titleSuffix: titleSuffix, - targetDate: targetDate.flatMap { pollDateFormatter.date(from: $0) }, - status: PollStatus(rawValue: status), - options: options - .sorted { $0.displayOrder < $1.displayOrder } - .map { $0.toDomain() } - ) - } -} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index e98c3de..4abab60 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -24,6 +24,20 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { self.provider = provider } + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + let dto: BattleDetailResponseDTO = try await provider.request( + .detail(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 상세 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty battleDetail payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } + public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { let dto: PreVoteResponseDTO = try await provider.request( .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) diff --git a/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift b/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift deleted file mode 100644 index d05597a..0000000 --- a/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// PollRepositoryImpl.swift -// Repository -// - -import Foundation - -import DomainInterface -import Entity -import Model -import Service - -import LogMacro -import Moya - -@preconcurrency import AsyncMoya - -public final class PollRepositoryImpl: PollInterface, @unchecked Sendable { - private let provider: MoyaProvider - - public init( - provider: MoyaProvider = MoyaProvider.authorized - ) { - self.provider = provider - } - - public func fetchPoll(pollId: Int) async throws -> PollDetail { - let dto: PollResponseDTO = try await provider.request(.detailPoll(pollId: pollId)) - - guard let data = dto.data else { - let message = dto.error?.message ?? "투표 데이터 응답이 비어 있습니다" - Log.error("[PollRepositoryImpl] empty poll payload: \(message)") - throw AuthError.backendError(message) - } - - return data.toDomain() - } -} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 856c45e..988bea3 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -11,6 +11,7 @@ import Foundations import AsyncMoya public enum BattleService { + case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) } @@ -22,10 +23,12 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { + case let .detail(battleId): + BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): - return BattleAPI.preVote(battleId: battleId).description + BattleAPI.preVote(battleId: battleId).description case let .scenario(battleId): - return BattleAPI.scenario(battleId: battleId).description + BattleAPI.scenario(battleId: battleId).description } } @@ -33,23 +36,27 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { + case .detail: + .get case .preVote: - return .post + .post case .scenario: - return .get + .get } } public var parameters: [String: Any]? { switch self { + case .detail: + nil case let .preVote(_, body): - return body.toDictionary + body.toDictionary case .scenario: - return nil + nil } } public var headers: [String: String]? { - return APIHeader.baseHeader + APIHeader.baseHeader } } diff --git a/Projects/Data/Service/Sources/Poll/PollService.swift b/Projects/Data/Service/Sources/Poll/PollService.swift deleted file mode 100644 index ee5bd20..0000000 --- a/Projects/Data/Service/Sources/Poll/PollService.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// PollService.swift -// Service -// -// Created by Wonji Suh on 5/19/26. -// - -import Foundation - -import API -import Foundations - -import AsyncMoya - -public enum PollService { - case detailPoll(pollId: Int) -} - -extension PollService: BaseTargetType { - public typealias Domain = PieckeDomain - - public var domain: PieckeDomain { .poll } - - public var urlPath: String { - switch self { - case let .detailPoll(pollId): - return PollAPI.detailPoll(pollId: pollId).description - } - } - - public var error: [Int: AsyncMoya.NetworkError]? { nil } - - public var method: Moya.Method { - switch self { - case .detailPoll: - return .get - } - } - - public var parameters: [String: Any]? { nil } - - public var headers: [String: String]? { - return APIHeader.baseHeader - } -} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 32a26e0..09f7d8b 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -8,6 +8,7 @@ import Foundation import WeaveDI public protocol BattleInterface: Sendable { + func fetchBattle(battleId: Int) async throws -> BattleDetail func submitPreVote(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 7c04df1..5d5894a 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -9,6 +9,29 @@ import Foundation public struct DefaultBattleRepositoryImpl: BattleInterface { public init() {} + public func fetchBattle(battleId _: Int) async throws -> BattleDetail { + BattleDetail( + battleInfo: BattleInfo( + battleId: 0, + title: "", + summary: "", + thumbnailUrl: "", + viewCount: 0, + participantsCount: 0, + audioDuration: 0, + tags: [], + options: [] + ), + description: "", + shareUrl: "", + userVoteStatus: .none, + currentStep: .none, + categoryTags: [], + philosopherTags: [], + valueTags: [] + ) + } + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } diff --git a/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift deleted file mode 100644 index b0034fd..0000000 --- a/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// DefaultPollRepositoryImpl.swift -// DomainInterface -// - -import Entity -import Foundation - -public struct DefaultPollRepositoryImpl: PollInterface { - public init() {} - - public func fetchPoll(pollId _: Int) async throws -> PollDetail { - .mock - } -} diff --git a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift b/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift deleted file mode 100644 index 8b41e10..0000000 --- a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// PollInterface.swift -// DomainInterface -// - -import Entity -import Foundation -import WeaveDI - -public protocol PollInterface: Sendable { - func fetchPoll(pollId: Int) async throws -> PollDetail -} - -public struct PollRepositoryDependency: DependencyKey { - public static var liveValue: PollInterface { - return UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() - } - - public static var testValue: PollInterface { - return UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() - } - - public static var previewValue: PollInterface = liveValue -} - -public extension DependencyValues { - var pollRepository: PollInterface { - get { self[PollRepositoryDependency.self] } - set { self[PollRepositoryDependency.self] = newValue } - } -} diff --git a/Projects/Domain/Entity/Sources/Home/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/BattleDetail.swift new file mode 100644 index 0000000..f5c26c7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleDetail.swift @@ -0,0 +1,131 @@ +// +// BattleDetail.swift +// Entity +// +// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. +// + +import Foundation + +public struct BattleDetail: Equatable, Identifiable { + public let battleInfo: BattleInfo + public let description: String + public let shareUrl: String + public let userVoteStatus: UserVoteStatus + public let currentStep: BattleStep + public let categoryTags: [BattleTag] + public let philosopherTags: [BattleTag] + public let valueTags: [BattleTag] + + public var id: Int { battleInfo.battleId } + + public init( + battleInfo: BattleInfo, + description: String, + shareUrl: String, + userVoteStatus: UserVoteStatus, + currentStep: BattleStep, + categoryTags: [BattleTag], + philosopherTags: [BattleTag], + valueTags: [BattleTag] + ) { + self.battleInfo = battleInfo + self.description = description + self.shareUrl = shareUrl + self.userVoteStatus = userVoteStatus + self.currentStep = currentStep + self.categoryTags = categoryTags + self.philosopherTags = philosopherTags + self.valueTags = valueTags + } +} + +public struct BattleInfo: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTag] + public let options: [BattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + thumbnailUrl: String, + viewCount: Int, + participantsCount: Int, + audioDuration: Int, + tags: [BattleTag], + options: [BattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.thumbnailUrl = thumbnailUrl + self.viewCount = viewCount + self.participantsCount = participantsCount + self.audioDuration = audioDuration + self.tags = tags + self.options = options + } +} + +public struct BattleOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTag] + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String, + title: String, + stance: String, + representative: String, + imageUrl: String, + tags: [BattleTag] + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + self.tags = tags + } +} + +public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case pro = "PRO" + case con = "CON" + case unknown + + public init(rawValue: String) { + self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} + +public enum BattleStep: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case preVote = "PRE_VOTE" + case listening = "LISTENING" + case postVote = "POST_VOTE" + case finished = "FINISHED" + case unknown + + public init(rawValue: String) { + self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/BattleTag.swift index 005de43..f585003 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/BattleTag.swift @@ -26,6 +26,7 @@ public enum TagType: String, Equatable, Hashable, Decodable { case philosopher = "PHILOSOPHER" case category = "CATEGORY" case era = "ERA" + case value = "VALUE" case unknown public init(rawValue: String) { @@ -33,6 +34,7 @@ public enum TagType: String, Equatable, Hashable, Decodable { case "PHILOSOPHER": self = .philosopher case "CATEGORY": self = .category case "ERA": self = .era + case "VALUE": self = .value default: self = .unknown } } diff --git a/Projects/Domain/Entity/Sources/Poll/PollDetail.swift b/Projects/Domain/Entity/Sources/Poll/PollDetail.swift deleted file mode 100644 index cb81e59..0000000 --- a/Projects/Domain/Entity/Sources/Poll/PollDetail.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// PollDetail.swift -// Entity -// -// `GET /api/v1/polls/{pollId}` 응답에서 사용하는 도메인 모델. -// - -import Foundation - -public struct PollDetail: Equatable, Identifiable { - public let pollId: Int - public let titlePrefix: String - public let titleSuffix: String - public let targetDate: Date? - public let status: PollStatus - public let options: [PollOption] - - public var id: Int { pollId } - - public init( - pollId: Int, - titlePrefix: String, - titleSuffix: String, - targetDate: Date?, - status: PollStatus, - options: [PollOption] - ) { - self.pollId = pollId - self.titlePrefix = titlePrefix - self.titleSuffix = titleSuffix - self.targetDate = targetDate - self.status = status - self.options = options - } -} - -public struct PollOption: Equatable, Identifiable, Hashable { - public let optionId: Int - public let label: String - public let title: String - public let displayOrder: Int - public let voteCount: Int - - public var id: Int { optionId } - - public init( - optionId: Int, - label: String, - title: String, - displayOrder: Int, - voteCount: Int - ) { - self.optionId = optionId - self.label = label - self.title = title - self.displayOrder = displayOrder - self.voteCount = voteCount - } -} - -public enum PollStatus: String, Equatable, Hashable, CaseIterable { - case pending = "PENDING" - case active = "ACTIVE" - case closed = "CLOSED" - case unknown - - public init(rawValue: String) { - self = PollStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} - -public extension PollDetail { - /// 전체 참여 수 = options.voteCount 의 합 - var totalVoteCount: Int { options.reduce(0) { $0 + $1.voteCount } } - - /// 옵션별 비율 (0~100). totalVoteCount 가 0 이면 모두 0. - func percentage(for option: PollOption) -> Int { - guard totalVoteCount > 0 else { return 0 } - return Int((Double(option.voteCount) / Double(totalVoteCount)) * 100) - } -} - -public extension PollDetail { - static let mock = PollDetail( - pollId: 1, - titlePrefix: "도덕의 기준은", - titleSuffix: "이다", - targetDate: nil, - status: .active, - options: [ - .init(optionId: 1, label: "A", title: "결과", displayOrder: 1, voteCount: 45), - .init(optionId: 2, label: "B", title: "의도", displayOrder: 2, voteCount: 25), - .init(optionId: 3, label: "C", title: "규칙", displayOrder: 3, voteCount: 20), - .init(optionId: 4, label: "D", title: "덕", displayOrder: 4, voteCount: 10), - ] - ) -} diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 0b8c8ef..8dfca2e 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -60,8 +60,8 @@ extension HomeCoordinator { action: IndexedRouterActionOf ) -> Effect { switch action { - case .routeAction(_, action: .home(.delegate(.presentPreVote))): - state.routes.push(.preVote(.init())) + case let .routeAction(_, action: .home(.delegate(.presentPreVote(battleId)))): + state.routes.push(.preVote(.init(battleId: battleId))) return .none case .routeAction(_, action: .preVote(.delegate(.dismiss))): diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift index 6f8fc8c..6fce991 100644 --- a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift @@ -19,19 +19,20 @@ public struct PreVoteFeature { @ObservableState public struct State: Equatable { public var battle: PreVoteBattle = .mock - public var poll: PollDetail? + public var battleDetail: BattleDetail? public var selectedSide: PhilosopherAvatar? public var isLoading: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? - public var pollId: Int = 1 - public var battleId: Int = 41 + public var battleId: Int public var isPrimaryButtonEnabled: Bool { selectedSide != nil && !isSubmitting } - public init() {} + public init(battleId: Int = 0) { + self.battleId = battleId + } } /// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩. @@ -63,12 +64,12 @@ public struct PreVoteFeature { } public enum AsyncAction: Equatable { - case fetchPoll + case fetchBattleDetail case submitPreVote(battleId: Int, optionId: Int) } public enum InnerAction: Equatable { - case pollResponse(Result) + case battleDetailResponse(Result) case preVoteResponse(Result) } @@ -78,11 +79,10 @@ public struct PreVoteFeature { } nonisolated enum CancelID: Hashable { - case fetchPoll + case fetchBattleDetail case submitPreVote } - @Dependency(\.pollRepository) private var pollRepository @Dependency(\.battleRepository) private var battleRepository public var body: some Reducer { @@ -115,21 +115,17 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: - guard state.poll == nil, !state.isLoading else { return .none } - return .send(.async(.fetchPoll)) + guard state.battleDetail == nil, !state.isLoading else { return .none } + return .send(.async(.fetchBattleDetail)) case .backButtonTapped: return .send(.delegate(.dismiss)) case .shareTapped: - let title = state.poll.map { "\($0.titlePrefix) \($0.titleSuffix)" } - ?? "\(state.battle.titleLine1) \(state.battle.titleLine2)" - state.shareItem = ShareItem( - items: [ - title, - "https://picke.store/poll/\(state.pollId)", - ] - ) + 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 case let .optionTapped(side): @@ -161,17 +157,17 @@ extension PreVoteFeature { action: AsyncAction ) -> Effect { switch action { - case .fetchPoll: + case .fetchBattleDetail: state.isLoading = true - let pollId = state.pollId - return .run { [repository = pollRepository] send in + let battleId = state.battleId + return .run { [repository = battleRepository] send in let result = await Result { - try await repository.fetchPoll(pollId: pollId) + try await repository.fetchBattle(battleId: battleId) } .mapError(AuthError.from) - return await send(.inner(.pollResponse(result))) + return await send(.inner(.battleDetailResponse(result))) } - .cancellable(id: CancelID.fetchPoll, cancelInFlight: true) + .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) case let .submitPreVote(battleId, optionId): return .run { [repository = battleRepository] send in @@ -190,14 +186,14 @@ extension PreVoteFeature { action: InnerAction ) -> Effect { switch action { - case let .pollResponse(result): + case let .battleDetailResponse(result): state.isLoading = false switch result { - case let .success(poll): - state.poll = poll - state.battle = makeBattle(from: poll, fallback: state.battle) + case let .success(detail): + state.battleDetail = detail + state.battle = makeBattle(from: detail, fallback: state.battle) case let .failure(error): - Log.error("[PreVoteFeature] fetchPoll failed: \(error.localizedDescription)") + Log.error("[PreVoteFeature] fetchBattle failed: \(error.localizedDescription)") } return .none @@ -213,18 +209,20 @@ extension PreVoteFeature { } } - /// API 로 받은 PollDetail 을 화면 모델 PreVoteBattle 로 매핑. - /// background/summary/tags 는 응답에 없으므로 fallback (이전 state.battle) 값을 유지한다. - /// 옵션은 displayOrder 순으로 앞에서부터 2개만 좌/우 카드에 매핑. + /// API 로 받은 BattleDetail 을 화면 모델 PreVoteBattle 로 매핑. + /// 옵션 0, 1 만 좌/우 카드에 매핑 (label A→left, B→right). private func makeBattle( - from poll: PollDetail, + from detail: BattleDetail, fallback: PreVoteBattle ) -> PreVoteBattle { + let info = detail.battleInfo let philosophers: [PhilosopherAvatar] = [.plato, .sartre, .sunja] - let mapped = poll.options.enumerated().map { idx, option in + let mapped = info.options.enumerated().map { idx, option in PreVoteOption( optionId: option.optionId, - philosopher: philosophers[safe: idx] ?? .plato, + philosopher: avatar(for: option.representative) + ?? philosophers[safe: idx] + ?? .plato, stance: option.title ) } @@ -232,17 +230,21 @@ extension PreVoteFeature { let rightOption = mapped[safe: 1] ?? fallback.rightOption return PreVoteBattle( - battleId: poll.pollId, - backgroundImageURL: fallback.backgroundImageURL, - tags: fallback.tags, - titleLine1: poll.titlePrefix, - titleLine2: poll.titleSuffix, - summary: fallback.summary, + battleId: info.battleId, + backgroundImageURL: info.thumbnailUrl.isEmpty ? fallback.backgroundImageURL : info.thumbnailUrl, + tags: detail.categoryTags.map { "#\($0.name)" }, + titleLine1: info.title, + titleLine2: "", + summary: detail.description.isEmpty ? info.summary : detail.description, leftOption: leftOption, rightOption: rightOption ) } + private func avatar(for representative: String) -> PhilosopherAvatar? { + PhilosopherAvatar.allCases.first { $0.rawValue == representative } + } + private func handleDelegateAction( state _: inout State, action: DelegateAction diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index 9ed57d9..e212854 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -41,7 +41,7 @@ public struct PreVoteView: View { } private var shouldShowSkeleton: Bool { - store.isLoading && store.poll == nil + store.isLoading && store.battleDetail == nil } @ViewBuilder From 6c05bb603a7c1bbea77fe461180c078d10e6c4ff Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:04:05 +0900 Subject: [PATCH 17/34] =?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=20userVoteStatus/currentStep=20n?= =?UTF-8?q?ull=20=ED=97=88=EC=9A=A9=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BattleDetailDataDTO.userVoteStatus / currentStep 을 Optional 로 변경 (서버가 null 로 내려주는 케이스 디코딩 실패 해결) - 매퍼: nil 이면 UserVoteStatus.none / BattleStep.none 으로 폴백 --- .../Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift | 4 ++-- .../Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift index b29694c..d10276c 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -9,8 +9,8 @@ public struct BattleDetailDataDTO: Decodable { public let battleInfo: BattleInfoDTO public let description: String public let shareUrl: String - public let userVoteStatus: String - public let currentStep: String + public let userVoteStatus: String? + public let currentStep: String? public let categoryTags: [BattleTagDTO] public let philosopherTags: [BattleTagDTO] public let valueTags: [BattleTagDTO] diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift index 31d37a5..b356d46 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -12,8 +12,8 @@ public extension BattleDetailDataDTO { battleInfo: battleInfo.toDomain(), description: description, shareUrl: shareUrl, - userVoteStatus: UserVoteStatus(rawValue: userVoteStatus), - currentStep: BattleStep(rawValue: currentStep), + userVoteStatus: userVoteStatus.map { UserVoteStatus(rawValue: $0) } ?? .none, + currentStep: currentStep.map { BattleStep(rawValue: $0) } ?? .none, categoryTags: categoryTags.map { $0.toDomain() }, philosopherTags: philosopherTags.map { $0.toDomain() }, valueTags: valueTags.map { $0.toDomain() } From 8e5c06c84e38ab2df8f7fb80c10f2bc81d18f629 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:10:45 +0900 Subject: [PATCH 18/34] =?UTF-8?q?fix:=20=EC=82=AC=EC=A0=84=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=99=94=EB=A9=B4=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B9=A8=EC=A7=90=20=ED=95=B4=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 본문 콘텐츠를 ScrollView 로 감싸고 navigationBar 를 safeAreaInset(top) 으로 분리 - 콘텐츠가 길어져도 화면 밖으로 밀려 잘리지 않음 - back / share 가 hero 이미지 위에 항상 표시 - contentArea 의 .padding(.top, 380) 으로 hero 노출 영역 확보 --- .../Home/Sources/Vote/View/PreVoteView.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index e212854..626856e 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -32,6 +32,10 @@ public struct PreVoteView: View { .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) + .safeAreaInset(edge: .top, spacing: 0) { + navigationBar + .background(Color.clear) + } .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) @@ -46,15 +50,16 @@ public struct PreVoteView: View { @ViewBuilder private var loadedContent: some View { - ZStack(alignment: .top) { - backgroundImage - - VStack(spacing: 0) { - navigationBar - Spacer(minLength: 0) + ScrollView(showsIndicators: false) { + ZStack(alignment: .top) { + backgroundImage contentArea + .padding(.top, 380) } + .frame(maxWidth: .infinity) } + .scrollBounceBehavior(.basedOnSize) + .ignoresSafeArea(edges: .top) } } From e4bfe26e9732d29aa13d371bc190a1c3f21e901e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:13:44 +0900 Subject: [PATCH 19/34] =?UTF-8?q?fix:=20=EC=82=AC=EC=A0=84=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=99=94=EB=A9=B4=20hero/=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20overlap=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZStack(top alignment) + padding(top: 380) 으로 인한 width 추론 / padding 누락 문제 해결 - ScrollView 안에 VStack(spacing: -120) 으로 hero 와 콘텐츠가 자연스럽게 겹치도록 변경 - backgroundImage: frame(maxWidth: .infinity) 명시 + ignoresSafeArea 는 ScrollView 한 곳에서만 적용 --- .../Presentation/Home/Sources/Vote/View/PreVoteView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index 626856e..4dd46ea 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -51,10 +51,9 @@ public struct PreVoteView: View { @ViewBuilder private var loadedContent: some View { ScrollView(showsIndicators: false) { - ZStack(alignment: .top) { + VStack(spacing: -120) { backgroundImage contentArea - .padding(.top, 380) } .frame(maxWidth: .infinity) } @@ -82,10 +81,9 @@ extension PreVoteView { Color.black.opacity(0.4) } + .frame(maxWidth: .infinity) .frame(height: 512) .clipped() - .frame(maxWidth: .infinity, alignment: .top) - .ignoresSafeArea(edges: .top) } } From a537315f1eee6b18d7fce41da535f8e3f5aeeb9e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:17:54 +0900 Subject: [PATCH 20/34] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=9D=84=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A0=8C=EB=8D=94=EB=A7=81=20+=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=ED=88=AC=ED=91=9C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomFeature.State - messages: scenario.nodes[*].scripts 를 ChatMessage 로 매핑 (speakerType A→left, B→right) - totalDuration: scenario.nodes.audioDuration 합산값 우선 사용 - audioUrl: scenario.audios[recommendedPathKey] 우선, 폴백으로 첫 항목 - 화자(speakerName) 으로 PhilosopherAvatar 매칭, 매칭 실패 시 .plato 폴백 - PreVoteView - loadedContent 를 GeometryReader 안 ScrollView 로 감싸 width 안정화 - contentArea 패딩 / titleText·summaryText multilineTextAlignment & lineLimit(nil) - optionCard minHeight 121 + minimumScaleFactor 로 글자 잘림 방지 --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 35 +++++++++++++++++-- .../Home/Sources/Vote/View/PreVoteView.swift | 31 ++++++++++------ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 7746910..ab74d22 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -32,12 +32,43 @@ public struct ChatRoomFeature { /// 선택지 영역에서 사용자가 탭한 옵션 label public var selectedOptionLabel: String? - public var totalDuration: TimeInterval { bundle.totalDuration } + public var totalDuration: TimeInterval { + guard let scenario else { return bundle.totalDuration } + let nodesTotal = scenario.nodes.reduce(0) { $0 + $1.audioDuration } + return nodesTotal > 0 ? TimeInterval(nodesTotal) : bundle.totalDuration + } + public var battleTitle: String { scenario?.title ?? bundle.battleTitle } - public var messages: [ChatMessage] { bundle.messages } + + public var messages: [ChatMessage] { + guard let scenario else { return bundle.messages } + return scenario.nodes.flatMap { node in + node.scripts.map { script in + let isRight = script.speakerType.rawValue == "B" + return ChatMessage( + speaker: ChatSpeaker( + philosopher: avatar(for: script.speakerName), + side: isRight ? .right : .left + ), + text: script.text + ) + } + } + } + + public var audioUrl: String? { + guard let scenario else { return nil } + if let url = scenario.audios[scenario.recommendedPathKey.rawValue] { return url } + return scenario.audios.values.first + } + // TODO: 음원 실재생 연결 후 hasFinishedListening 게이트로 복원 public var canScrub: Bool { true } + private func avatar(for name: String) -> PhilosopherAvatar { + PhilosopherAvatar.allCases.first { $0.rawValue == name } ?? .plato + } + public var currentNode: ScenarioNode? { guard let scenario else { return nil } let target = currentNodeId ?? scenario.startNodeId diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index 4dd46ea..352d7a0 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -50,15 +50,17 @@ public struct PreVoteView: View { @ViewBuilder private var loadedContent: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: -120) { - backgroundImage - contentArea + GeometryReader { proxy in + ScrollView(showsIndicators: false) { + VStack(spacing: -120) { + backgroundImage + contentArea + } + .frame(width: proxy.size.width) } - .frame(maxWidth: .infinity) + .scrollBounceBehavior(.basedOnSize) + .ignoresSafeArea(edges: .top) } - .scrollBounceBehavior(.basedOnSize) - .ignoresSafeArea(edges: .top) } } @@ -116,7 +118,7 @@ extension PreVoteView { optionSection primaryButton } - .padding(.horizontal, 16) + .padding(.horizontal, 20) .padding(.top, 80) .padding(.bottom, 40) .background( @@ -166,7 +168,8 @@ extension PreVoteView { .foregroundStyle(.neutral500) .kerning(-0.6) .lineSpacing(24 * 0.4) - .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) } @@ -176,7 +179,8 @@ extension PreVoteView { .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) .lineSpacing(13 * 0.4) - .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -191,6 +195,7 @@ extension PreVoteView { optionCard(store.battle.leftOption) optionCard(store.battle.rightOption) } + .frame(maxWidth: .infinity) vsBadge } } @@ -210,15 +215,19 @@ extension PreVoteView { .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.neutral600) .kerning(-0.35) + .lineLimit(2) + .minimumScaleFactor(0.85) .multilineTextAlignment(.center) Text(option.philosopher.rawValue) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) + .lineLimit(1) + .minimumScaleFactor(0.85) .multilineTextAlignment(.center) } } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, minHeight: 121) .padding(8) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( From fe99ce3bfef3960e23b4c4eada4f5e2d8552f50f Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:30:01 +0900 Subject: [PATCH 21/34] =?UTF-8?q?feat:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=8B=A4=EC=9E=AC=EC=83=9D=20=ED=86=B5=ED=95=A9=20(Clean=20Arc?= =?UTF-8?q?hitecture)=20+=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20mp3=20=EC=97=B0=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain/DomainInterface - AudioPlayerInterface 프로토콜 + DefaultAudioPlayerImpl + AudioPlayerDependency - DependencyValues.audioPlayer 노출 - Data/Repository - AudioPlayerRepositoryImpl + 내부 AudioPlayerService (AVPlayer @MainActor) - addPeriodicTimeObserver → AsyncStream 으로 currentTime 멀티 구독 - load / play / pause / seek / duration / currentTimes - App DI: AudioPlayerRepositoryImpl as AudioPlayerInterface 등록 - ChatRoomFeature - mock tick(startTicking/stopTicking) 제거 → audioPlayer 실재생 연결 - onAppear 에서 subscribePlayer + fetchScenario 트리거 - scenarioResponse 성공 시 audios[recommendedPathKey] URL 로 player.load - togglePlay / seekBackward·Forward / scrub / refresh / back 모두 player API 호출 - playerDuration / playerTimeUpdated InnerAction 으로 실시간 갱신, totalDuration 우선 적용 --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../AudioPlayerRepositoryImpl.swift | 117 ++++++++++++++++ .../AudioPlayer/AudioPlayerInterface.swift | 48 +++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 130 ++++++++++++------ .../Home/Sources/Vote/View/PreVoteView.swift | 29 ++-- 5 files changed, 275 insertions(+), 50 deletions(-) create mode 100644 Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 0aa5efd..37bb5fe 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 { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift new file mode 100644 index 0000000..08388a0 --- /dev/null +++ b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift @@ -0,0 +1,117 @@ +// +// AudioPlayerRepositoryImpl.swift +// Repository +// +// AVPlayer 기반 단일 오디오 플레이어 구현체. +// AudioPlayerInterface 를 만족하며, 채팅방 / 배틀 상세 등 한 화면당 하나의 음원이 +// 재생되는 환경을 가정한다. +// + +import AVFoundation +import DomainInterface +import Foundation + +public final class AudioPlayerRepositoryImpl: AudioPlayerInterface, @unchecked Sendable { + private let service: AudioPlayerService + + public init() { + service = AudioPlayerService.shared + } + + public func load(url: URL) async { + await service.load(url: url) + } + + public func play() async { + await service.play() + } + + public func pause() async { + await service.pause() + } + + public func seek(to time: TimeInterval) async { + await service.seek(to: time) + } + + public func duration() async -> TimeInterval { + await service.duration() + } + + public func currentTimes() -> AsyncStream { + AsyncStream { continuation in + let task = Task { @MainActor in + for await time in service.currentTimeStream() { + continuation.yield(time) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +@MainActor +final class AudioPlayerService { + static let shared = AudioPlayerService() + + private let player = AVPlayer() + private var timeContinuations: [UUID: AsyncStream.Continuation] = [:] + private var timeObserverToken: Any? + + private init() { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + installPeriodicObserver() + } + + private func installPeriodicObserver() { + let interval = CMTime(seconds: 0.25, preferredTimescale: 600) + timeObserverToken = player.addPeriodicTimeObserver( + forInterval: interval, + queue: .main + ) { [weak self] time in + let seconds = max(0, time.seconds.isFinite ? time.seconds : 0) + Task { @MainActor [weak self] in + self?.timeContinuations.values.forEach { $0.yield(seconds) } + } + } + } + + func load(url: URL) { + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + player.seek(to: .zero) + } + + func play() { player.play() } + func pause() { player.pause() } + + func seek(to time: TimeInterval) async { + await player.seek( + to: CMTime(seconds: max(0, time), preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + func duration() async -> TimeInterval { + guard let item = player.currentItem else { return 0 } + if let duration = try? await item.asset.load(.duration) { + return duration.seconds.isFinite ? duration.seconds : 0 + } + return 0 + } + + func currentTimeStream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + timeContinuations[id] = continuation + continuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor in + self?.timeContinuations.removeValue(forKey: id) + } + } + } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift new file mode 100644 index 0000000..e8f9f2e --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift @@ -0,0 +1,48 @@ +// +// AudioPlayerInterface.swift +// DomainInterface +// +// 채팅방 / 배틀 상세 등 한 번에 하나의 음원만 재생되는 환경을 위한 단일 플레이어 인터페이스. +// + +import Dependencies +import Foundation +import WeaveDI + +public protocol AudioPlayerInterface: Sendable { + func load(url: URL) async + func play() async + func pause() async + func seek(to time: TimeInterval) async + func duration() async -> TimeInterval + func currentTimes() -> AsyncStream +} + +public struct DefaultAudioPlayerImpl: AudioPlayerInterface { + public init() {} + public func load(url _: URL) async {} + public func play() async {} + public func pause() async {} + public func seek(to _: TimeInterval) async {} + public func duration() async -> TimeInterval { 0 } + public func currentTimes() -> AsyncStream { AsyncStream { _ in } } +} + +public struct AudioPlayerDependency: DependencyKey { + public static var liveValue: AudioPlayerInterface { + UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() + } + + public static var testValue: AudioPlayerInterface { + UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() + } + + public static var previewValue: AudioPlayerInterface = liveValue +} + +public extension DependencyValues { + var audioPlayer: AudioPlayerInterface { + get { self[AudioPlayerDependency.self] } + set { self[AudioPlayerDependency.self] = newValue } + } +} diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index ab74d22..713645d 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -22,6 +22,7 @@ public struct ChatRoomFeature { public var scenario: BattleScenario? public var isPlaying: Bool = false public var currentTime: TimeInterval = 0 + public var playerDuration: TimeInterval = 0 public var battleId: Int = 0 public var isLoadingScenario: Bool = false /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 @@ -33,6 +34,7 @@ public struct ChatRoomFeature { public var selectedOptionLabel: String? public var totalDuration: TimeInterval { + if playerDuration > 0 { return playerDuration } guard let scenario else { return bundle.totalDuration } let nodesTotal = scenario.nodes.reduce(0) { $0 + $1.audioDuration } return nodesTotal > 0 ? TimeInterval(nodesTotal) : bundle.totalDuration @@ -62,7 +64,6 @@ public struct ChatRoomFeature { return scenario.audios.values.first } - // TODO: 음원 실재생 연결 후 hasFinishedListening 게이트로 복원 public var canScrub: Bool { true } private func avatar(for name: String) -> PhilosopherAvatar { @@ -113,14 +114,15 @@ public struct ChatRoomFeature { } public enum AsyncAction: Equatable { - case startTicking - case stopTicking case fetchScenario + case loadAudio(URL) + case subscribePlayer } public enum InnerAction: Equatable { - case tick case scenarioResponse(Result) + case playerTimeUpdated(TimeInterval) + case playerDurationUpdated(TimeInterval) } public enum DelegateAction: Equatable { @@ -128,12 +130,12 @@ public struct ChatRoomFeature { } nonisolated enum CancelID: Hashable { - case tick case fetchScenario + case audioObserver } - @Dependency(\.continuousClock) var clock @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.audioPlayer) private var audioPlayer public var body: some Reducer { BindingReducer() @@ -161,32 +163,61 @@ extension ChatRoomFeature { ) -> Effect { switch action { case .onAppear: - guard state.scenario == nil, !state.isLoadingScenario else { return .none } - return .send(.async(.fetchScenario)) + let needsFetch = state.scenario == nil && !state.isLoadingScenario + let subscribe: Effect = .send(.async(.subscribePlayer)) + return needsFetch + ? subscribe.merge(with: .send(.async(.fetchScenario))) + : subscribe + case .backButtonTapped: - return .send(.async(.stopTicking)).concatenate(with: .send(.delegate(.dismiss))) + return .run { [player = audioPlayer] send in + await player.pause() + await send(.delegate(.dismiss)) + } + case .refreshTapped: state.currentTime = 0 state.isPlaying = false - return .send(.async(.stopTicking)) + return .run { [player = audioPlayer] _ in + await player.pause() + await player.seek(to: 0) + } + case .togglePlayTapped: state.isPlaying.toggle() - return state.isPlaying ? .send(.async(.startTicking)) : .send(.async(.stopTicking)) + let playing = state.isPlaying + return .run { [player = audioPlayer] _ in + if playing { await player.play() } else { await player.pause() } + } + case .seekBackwardTapped: guard state.canScrub else { return .none } - state.currentTime = max(0, state.currentTime - 15) - return .none + let target = max(0, state.currentTime - 15) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + case .seekForwardTapped: guard state.canScrub else { return .none } - state.currentTime = min(state.totalDuration, state.currentTime + 15) - return .none + let target = min(state.totalDuration, state.currentTime + 15) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + case let .scrub(time): guard state.canScrub else { return .none } - state.currentTime = min(max(0, time), state.totalDuration) - return .none + let target = min(max(0, time), state.totalDuration) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + case let .optionTapped(label): state.selectedOptionLabel = (state.selectedOptionLabel == label) ? nil : label return .none + case .confirmOptionTapped: guard let label = state.selectedOptionLabel, let option = state.interactiveOptions.first(where: { $0.label == label }) @@ -196,23 +227,15 @@ extension ChatRoomFeature { state.currentTime = 0 state.hasFinishedListening = false state.isPlaying = false - return .send(.async(.stopTicking)) + return .run { [player = audioPlayer] _ in + await player.pause() + await player.seek(to: 0) + } } } private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { switch action { - case .startTicking: - return .run { [clock] send in - for await _ in clock.timer(interval: .seconds(1)) { - await send(.inner(.tick)) - } - } - .cancellable(id: CancelID.tick, cancelInFlight: true) - - case .stopTicking: - return .cancel(id: CancelID.tick) - case .fetchScenario: state.isLoadingScenario = true let battleId = state.battleId @@ -224,22 +247,30 @@ extension ChatRoomFeature { return await send(.inner(.scenarioResponse(result))) } .cancellable(id: CancelID.fetchScenario, cancelInFlight: true) + + case let .loadAudio(url): + state.currentTime = 0 + state.playerDuration = 0 + return .run { [player = audioPlayer] send in + await player.load(url: url) + let duration = await player.duration() + if duration > 0 { + await send(.inner(.playerDurationUpdated(duration))) + } + } + + case .subscribePlayer: + return .run { [player = audioPlayer] send in + for await time in player.currentTimes() { + await send(.inner(.playerTimeUpdated(time))) + } + } + .cancellable(id: CancelID.audioObserver, cancelInFlight: true) } } private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { switch action { - case .tick: - let next = state.currentTime + 1 - if next >= state.totalDuration { - state.currentTime = state.totalDuration - state.isPlaying = false - state.hasFinishedListening = true - return .send(.async(.stopTicking)) - } - state.currentTime = next - return .none - case let .scenarioResponse(result): state.isLoadingScenario = false switch result { @@ -248,10 +279,29 @@ extension ChatRoomFeature { if state.currentNodeId == nil { state.currentNodeId = scenario.startNodeId } + if let urlString = scenario.audios[scenario.recommendedPathKey.rawValue] + ?? scenario.audios.values.first, + let url = URL(string: urlString) + { + return .send(.async(.loadAudio(url))) + } + return .none case let .failure(error): Log.error("[ChatRoomFeature] fetchScenario failed: \(error.localizedDescription)") + return .none + } + + case let .playerTimeUpdated(time): + state.currentTime = time + if state.totalDuration > 0, time >= state.totalDuration - 0.5 { + state.hasFinishedListening = true + state.isPlaying = false } return .none + + case let .playerDurationUpdated(duration): + state.playerDuration = duration + return .none } } diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index 352d7a0..8a2feb8 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -51,17 +51,26 @@ public struct PreVoteView: View { @ViewBuilder private var loadedContent: some View { GeometryReader { proxy in - ScrollView(showsIndicators: false) { - VStack(spacing: -120) { - backgroundImage - contentArea + ZStack(alignment: .top) { + backgroundImage + .frame(width: proxy.size.width) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Color.clear + .frame(height: Self.contentOverlapTopOffset) + + contentArea + } + .frame(width: proxy.size.width) } - .frame(width: proxy.size.width) + .scrollBounceBehavior(.basedOnSize) } - .scrollBounceBehavior(.basedOnSize) .ignoresSafeArea(edges: .top) } } + + private static let contentOverlapTopOffset: CGFloat = 392 } // MARK: - Background @@ -118,7 +127,7 @@ extension PreVoteView { optionSection primaryButton } - .padding(.horizontal, 20) + .padding(.horizontal, 24) .padding(.top, 80) .padding(.bottom, 40) .background( @@ -213,7 +222,7 @@ extension PreVoteView { VStack(spacing: 2) { Text(option.stance) .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.neutral600) + .foregroundStyle(.neutral700) .kerning(-0.35) .lineLimit(2) .minimumScaleFactor(0.85) @@ -232,9 +241,9 @@ extension PreVoteView { .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(isSelected ? .primary500 : .beige600, lineWidth: isSelected ? 1.5 : 1) + .stroke(isSelected ? .beige700 : .beige500, lineWidth: 1) ) - .opacity(isSelected ? 1.0 : 0.88) + .opacity(isSelected ? 1.0 : 0.72) } .buttonStyle(.plain) } From 0fe5547b97b8aca58a281dc3c349b2de179407db Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 04:59:17 +0900 Subject: [PATCH 22/34] =?UTF-8?q?fix:=20=EC=82=AC=EC=A0=84=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=ED=99=94=EB=A9=B4=20=EB=92=A4=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=ED=84=B0=EC=B9=98=EA=B0=80=20ScrollView=20?= =?UTF-8?q?=EB=A1=9C=20=ED=9D=98=EB=9F=AC=EA=B0=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safeAreaInset(.top) 안 navigationBar 는 ignoresSafeArea 적용된 ScrollView 와 히트 영역이 겹쳐 터치가 스크롤로 흘러갔다 - overlay(.top) + 명시 zIndex(10) 로 분리, topInset 만큼만 padding 적용 - topInset: UIWindowScene keyWindow 의 safeAreaInsets.top (없으면 47) --- .../Home/Sources/Vote/View/PreVoteView.swift | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index 8a2feb8..b769afc 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -32,9 +32,11 @@ public struct PreVoteView: View { .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) - .safeAreaInset(edge: .top, spacing: 0) { + .overlay(alignment: .top) { navigationBar .background(Color.clear) + .padding(.top, topInset) + .zIndex(10) } .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in @@ -48,6 +50,14 @@ public struct PreVoteView: View { store.isLoading && store.battleDetail == nil } + private var topInset: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .safeAreaInsets.top ?? 47 + } + @ViewBuilder private var loadedContent: some View { GeometryReader { proxy in @@ -60,7 +70,9 @@ public struct PreVoteView: View { Color.clear .frame(height: Self.contentOverlapTopOffset) - contentArea + contentArea( + minHeight: max(0, proxy.size.height - Self.contentOverlapTopOffset) + ) } .frame(width: proxy.size.width) } @@ -70,7 +82,7 @@ public struct PreVoteView: View { } } - private static let contentOverlapTopOffset: CGFloat = 392 + private static let contentOverlapTopOffset: CGFloat = 290 } // MARK: - Background @@ -121,15 +133,20 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var contentArea: some View { + private func contentArea(minHeight: CGFloat) -> some View { VStack(spacing: 40) { contentSection optionSection + + Spacer(minLength: 40) + primaryButton } .padding(.horizontal, 24) .padding(.top, 80) .padding(.bottom, 40) + .frame(maxWidth: .infinity) + .frame(minHeight: minHeight, alignment: .top) .background( LinearGradient( stops: [ From 02b9f538b3008ac5aa8a57f3e85b5cdc718e4274 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 05:37:31 +0900 Subject: [PATCH 23/34] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=C2=B7=EC=82=AC=EC=A0=84=20=ED=88=AC=ED=91=9C=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0/=EB=B7=B0=20=EC=A0=95=EB=B9=84=20+=20?= =?UTF-8?q?=ED=99=94=EC=9E=90=20=EB=A7=A4=ED=95=91=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity - PreVoteBattle / ScenarioPhilosopher 등 화자 메타데이터 확장 - ChatMessage / ChatSpeaker 에 label·name·imageURL·center side 지원 - ChatRoomFeature - speakerType 별 화자 매핑 헬퍼 정리 (A→left, B→right, NARRATOR→center) - philosophers 배열의 imageUrl 을 ChatSpeaker.imageURL 로 연결 - ChatRoomView / PreVoteView / PreVoteSkeletonView - 화자 아바타를 KFImage URL 기반으로 변경 - 사전 투표 화면 CTA / 콘텐츠 패딩 분리 (overlay + bottom CTA 고정) --- .../Entity/Sources/Home/BattleScenario.swift | 2 + .../Entity/Sources/Home/ChatMessage.swift | 32 +++- .../Entity/Sources/Home/PreVoteBattle.swift | 29 +++- .../ChatRoom/Reducer/ChatRoomFeature.swift | 45 +++++- .../Sources/ChatRoom/View/ChatRoomView.swift | 58 +++++-- .../Sources/Vote/Reducer/PreVoteFeature.swift | 66 +++----- .../View/Components/PreVoteSkeletonView.swift | 37 +++-- .../Home/Sources/Vote/View/PreVoteView.swift | 148 ++++++++++-------- 8 files changed, 269 insertions(+), 148 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift index 8d47945..1c22953 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift +++ b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift @@ -120,6 +120,8 @@ public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { } public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { + case a = "A" + case b = "B" case narrator = "NARRATOR" case philosopher = "PHILOSOPHER" case unknown diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift index 42966f1..d06a3f9 100644 --- a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift +++ b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift @@ -10,16 +10,26 @@ import Foundation public enum ChatSpeakerSide: Equatable, Hashable { case left case right + case center } public struct ChatSpeaker: Equatable, Identifiable, Hashable { - public let philosopher: PhilosopherAvatar + public let label: String? + public let name: String + public let imageURL: String? public let side: ChatSpeakerSide - public var id: PhilosopherAvatar { philosopher } + public var id: String { "\(label ?? name)-\(side)" } - public init(philosopher: PhilosopherAvatar, side: ChatSpeakerSide) { - self.philosopher = philosopher + public init( + label: String? = nil, + name: String, + imageURL: String? = nil, + side: ChatSpeakerSide + ) { + self.label = label + self.name = name + self.imageURL = imageURL self.side = side } } @@ -66,8 +76,18 @@ public struct ChatRoomBundle: Equatable { public extension ChatRoomBundle { static let mock: ChatRoomBundle = { - let plato = ChatSpeaker(philosopher: .plato, side: .left) - let sartre = ChatSpeaker(philosopher: .sartre, side: .right) + let plato = ChatSpeaker( + label: "A", + name: "플라톤", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/plato.png", + side: .left + ) + let sartre = ChatSpeaker( + label: "B", + name: "사르트르", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/sartre.png", + side: .right + ) return ChatRoomBundle( battleTitle: "뒤샹의 변기, 예술인가 도발인가", totalDuration: 268, diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift index 61d1163..c60b040 100644 --- a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift @@ -44,14 +44,21 @@ public struct PreVoteBattle: Equatable, Identifiable { public struct PreVoteOption: Equatable, Identifiable, Hashable { public let optionId: Int - public let philosopher: PhilosopherAvatar + public let representative: String + public let imageURL: String public let stance: String - public var id: String { "\(optionId)-\(philosopher.rawValue)" } + public var id: Int { optionId } - public init(optionId: Int = 0, philosopher: PhilosopherAvatar, stance: String) { + public init( + optionId: Int, + representative: String, + imageURL: String, + stance: String + ) { self.optionId = optionId - self.philosopher = philosopher + self.representative = representative + self.imageURL = imageURL self.stance = stance } } @@ -75,7 +82,17 @@ public extension PreVoteBattle { 누군가는 현대 미술의 혁명이라고 부릅니다. 과연 이 변기의 '진짜 모습'은 무엇일까요? """, - leftOption: .init(philosopher: .plato, stance: "변기는 변기다"), - rightOption: .init(philosopher: .sartre, stance: "예술이다") + leftOption: .init( + optionId: 1, + representative: "플라톤", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/plato.png", + stance: "변기는 변기다" + ), + rightOption: .init( + optionId: 2, + representative: "사르트르", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/sartre.png", + stance: "예술이다" + ) ) } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 713645d..87b99c1 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -46,12 +46,8 @@ public struct ChatRoomFeature { guard let scenario else { return bundle.messages } return scenario.nodes.flatMap { node in node.scripts.map { script in - let isRight = script.speakerType.rawValue == "B" return ChatMessage( - speaker: ChatSpeaker( - philosopher: avatar(for: script.speakerName), - side: isRight ? .right : .left - ), + speaker: speaker(for: script, in: scenario), text: script.text ) } @@ -66,8 +62,43 @@ public struct ChatRoomFeature { public var canScrub: Bool { true } - private func avatar(for name: String) -> PhilosopherAvatar { - PhilosopherAvatar.allCases.first { $0.rawValue == name } ?? .plato + 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) + 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 + ) } public var currentNode: ScenarioNode? { diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 937dfb3..b3c63b4 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -12,10 +12,15 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +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 public init(store: StoreOf) { self.store = store @@ -103,35 +108,50 @@ extension ChatRoomView { @ViewBuilder private func messageGroup(_ group: SpeakerGroup) -> some View { - HStack(alignment: .top, spacing: 8) { - if group.speaker.side == .left { + switch group.speaker.side { + case .left: + HStack(alignment: .top, spacing: 8) { avatar(group.speaker) bubbleColumn(speaker: group.speaker, messages: group.messages) Spacer(minLength: 40) - } else { + } + + case .right: + HStack(alignment: .top, spacing: 8) { Spacer(minLength: 40) bubbleColumn(speaker: group.speaker, messages: group.messages) avatar(group.speaker) } + + case .center: + VStack(spacing: 6) { + ForEach(group.messages) { message in + narratorBubble(text: message.text) + } + } + .frame(maxWidth: .infinity) } } @ViewBuilder private func avatar(_ speaker: ChatSpeaker) -> some View { - Image(asset: speaker.philosopher.imageAsset) + KFImage(URL(string: speaker.imageURL ?? "")) + .placeholder { + SkeletonView(cornerRadius: Self.avatarSize / 2) + } .resizable() .scaledToFit() - .frame(width: 30, height: 40) - .frame(width: 40, height: 40) + .frame(width: Self.avatarImageWidth, height: Self.avatarImageHeight) + .frame(width: Self.avatarSize, height: Self.avatarSize) .background(.beige600, in: Circle()) } @ViewBuilder private func bubbleColumn(speaker: ChatSpeaker, messages: [ChatMessage]) -> some View { VStack(alignment: speaker.side == .left ? .leading : .trailing, spacing: 6) { - Text(speaker.philosopher.rawValue) + Text(speaker.name) .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.neutral800) + .foregroundStyle(.neutral500) .padding(.horizontal, 4) VStack(alignment: .leading, spacing: 6) { @@ -140,15 +160,18 @@ extension ChatRoomView { } } } + .frame(maxWidth: Self.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) } @ViewBuilder private func bubble(text: String, side: ChatSpeakerSide) -> some View { Text(text) .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral400) + .foregroundStyle(.neutral500) + .lineSpacing(13 * 0.4) .padding(.horizontal, 8) .padding(.vertical, 6) + .frame(maxWidth: Self.bubbleMaxWidth, alignment: .leading) .background( side == .left ? Color.beige50 : Color.beige400, in: RoundedRectangle(cornerRadius: 2) @@ -159,6 +182,23 @@ extension ChatRoomView { ) } + @ViewBuilder + private func narratorBubble(text: String) -> some View { + Text(text) + .pretendardFont(family: .Regular, size: 12) + .foregroundStyle(.neutral400) + .lineSpacing(12 * 0.4) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: 280) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + private struct SpeakerGroup: Equatable, Identifiable { let id = UUID() let speaker: ChatSpeaker diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift index 6fce991..3f3e06c 100644 --- a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift @@ -18,20 +18,21 @@ public struct PreVoteFeature { @ObservableState public struct State: Equatable { - public var battle: PreVoteBattle = .mock + public var battle: PreVoteBattle? public var battleDetail: BattleDetail? - public var selectedSide: PhilosopherAvatar? + public var selectedOptionId: Int? public var isLoading: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? public var battleId: Int public var isPrimaryButtonEnabled: Bool { - selectedSide != nil && !isSubmitting + selectedOptionId != nil && !isSubmitting } - public init(battleId: Int = 0) { + public init(battleId: Int = 0, battle: PreVoteBattle? = nil) { self.battleId = battleId + self.battle = battle } } @@ -59,7 +60,7 @@ public struct PreVoteFeature { case onAppear case backButtonTapped case shareTapped - case optionTapped(PhilosopherAvatar) + case optionTapped(optionId: Int) case primaryButtonTapped } @@ -115,43 +116,33 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: - guard state.battleDetail == nil, !state.isLoading else { return .none } + guard state.battleDetail == nil, + state.battle == nil, + !state.isLoading + else { return .none } return .send(.async(.fetchBattleDetail)) case .backButtonTapped: return .send(.delegate(.dismiss)) case .shareTapped: - let title = state.battleDetail?.battleInfo.title ?? state.battle.titleLine1 + 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 - case let .optionTapped(side): - state.selectedSide = (state.selectedSide == side) ? nil : side + case let .optionTapped(optionId): + state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId return .none case .primaryButtonTapped: - guard let side = state.selectedSide else { return .none } - guard let optionId = optionId(for: side, in: state.battle) else { - Log.error("[PreVoteFeature] optionId 매핑 실패 side=\(side)") - return .none - } + guard let optionId = state.selectedOptionId else { return .none } state.isSubmitting = true return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) } } - private func optionId( - for side: PhilosopherAvatar, - in battle: PreVoteBattle - ) -> Int? { - if battle.leftOption.philosopher == side { return battle.leftOption.optionId } - if battle.rightOption.philosopher == side { return battle.rightOption.optionId } - return nil - } - private func handleAsyncAction( state: inout State, action: AsyncAction @@ -191,7 +182,7 @@ extension PreVoteFeature { switch result { case let .success(detail): state.battleDetail = detail - state.battle = makeBattle(from: detail, fallback: state.battle) + state.battle = makeBattle(from: detail) case let .failure(error): Log.error("[PreVoteFeature] fetchBattle failed: \(error.localizedDescription)") } @@ -211,27 +202,26 @@ extension PreVoteFeature { /// API 로 받은 BattleDetail 을 화면 모델 PreVoteBattle 로 매핑. /// 옵션 0, 1 만 좌/우 카드에 매핑 (label A→left, B→right). - private func makeBattle( - from detail: BattleDetail, - fallback: PreVoteBattle - ) -> PreVoteBattle { + private func makeBattle(from detail: BattleDetail) -> PreVoteBattle? { let info = detail.battleInfo - let philosophers: [PhilosopherAvatar] = [.plato, .sartre, .sunja] - let mapped = info.options.enumerated().map { idx, option in + let mapped = info.options.map { option in PreVoteOption( optionId: option.optionId, - philosopher: avatar(for: option.representative) - ?? philosophers[safe: idx] - ?? .plato, + representative: option.representative, + imageURL: option.imageUrl, stance: option.title ) } - let leftOption = mapped[safe: 0] ?? fallback.leftOption - let rightOption = mapped[safe: 1] ?? fallback.rightOption + guard let leftOption = mapped[safe: 0], + let rightOption = mapped[safe: 1] + else { + Log.error("[PreVoteFeature] 서버 option 데이터 부족 count=\(mapped.count)") + return nil + } return PreVoteBattle( battleId: info.battleId, - backgroundImageURL: info.thumbnailUrl.isEmpty ? fallback.backgroundImageURL : info.thumbnailUrl, + backgroundImageURL: info.thumbnailUrl, tags: detail.categoryTags.map { "#\($0.name)" }, titleLine1: info.title, titleLine2: "", @@ -241,10 +231,6 @@ extension PreVoteFeature { ) } - private func avatar(for representative: String) -> PhilosopherAvatar? { - PhilosopherAvatar.allCases.first { $0.rawValue == representative } - } - private func handleDelegateAction( state _: inout State, action: DelegateAction diff --git a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift index 2a6c4ea..20778f6 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -2,7 +2,6 @@ // PreVoteSkeletonView.swift // Home // -// .pen `사전 투표창 - Skeleton Loader` (nTffe) 1:1 매핑. // PreVoteView 의 로딩 상태 placeholder. // @@ -13,6 +12,8 @@ import DesignSystem struct PreVoteSkeletonView: View { private static let designWidth: CGFloat = 375 private static let designHeight: CGFloat = 812 + private static let ctaHeight: CGFloat = 52 + private static let ctaBottomSpacing: CGFloat = 40 var body: some View { GeometryReader { proxy in @@ -21,31 +22,37 @@ struct PreVoteSkeletonView: View { ZStack(alignment: .topLeading) { Color.beige50 - // 상단 이미지 영역 - block(width: 375, height: 329.25, x: 0, y: 0) + // 상단 이미지 영역: PreVoteView backgroundImage 높이와 동일. + block(width: 375, height: 512, x: 0, y: 0, cornerRadius: 0) - // 헤더 영역 - block(width: 375, height: 60, x: 0, y: 70) + // Navigation bar + block(width: 20, height: 10, x: 20, y: 56, cornerRadius: 2) + block(width: 24, height: 24, x: 331, y: 50, cornerRadius: 2) // 태그 2개 - block(width: 29, height: 17, x: 22, y: 360) - block(width: 49, height: 17, x: 72, y: 360) + block(width: 29, height: 17, x: 20, y: 370) + block(width: 49, height: 17, x: 58, y: 370) // 타이틀 - block(width: 167, height: 68, x: 16, y: 399) + block(width: 210, height: 68, x: 20, y: 407) // 설명 - block(width: 235.5, height: 61.43, x: 16, y: 479) + block(width: 265, height: 61, x: 20, y: 487) // 좌/우 옵션 카드 - block(width: 167, height: 105.72, x: 14.625, y: 574) - block(width: 167, height: 105.72, x: 193.375, y: 574) + block(width: 163.5, height: 104, x: 20, y: 580) + block(width: 163.5, height: 104, x: 191.5, y: 580) - // VS 작은 점 - block(width: 15, height: 15, x: 373.5, y: 619, cornerRadius: 8) + // VS badge + block(width: 28, height: 28, x: 173.5, y: 632, cornerRadius: 14) - // CTA 영역 - block(width: 87, height: 24, x: 144, y: 734) + // CTA: picke.pen Button/Primary/Large 343x52, 하단 40. + block( + width: 343, + height: Self.ctaHeight, + x: 20, + y: Self.designHeight - Self.ctaBottomSpacing - Self.ctaHeight + ) } .frame(width: Self.designWidth, height: Self.designHeight, alignment: .topLeading) .scaleEffect(scale, anchor: .topLeading) diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index b769afc..d289bd8 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -35,9 +35,16 @@ public struct PreVoteView: View { .overlay(alignment: .top) { navigationBar .background(Color.clear) - .padding(.top, topInset) + .padding(.top, 12) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) .zIndex(10) } + .overlay(alignment: .bottom) { + primaryButton + .padding(.horizontal, Self.ctaHorizontalPadding) + .padding(.bottom, Self.ctaBottomSpacing) + } .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) @@ -47,51 +54,51 @@ public struct PreVoteView: View { } private var shouldShowSkeleton: Bool { - store.isLoading && store.battleDetail == nil - } - - private var topInset: CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first(where: \.isKeyWindow)? - .safeAreaInsets.top ?? 47 + store.isLoading || store.battle == nil } @ViewBuilder private var loadedContent: some View { - GeometryReader { proxy in - ZStack(alignment: .top) { - backgroundImage - .frame(width: proxy.size.width) - - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - Color.clear - .frame(height: Self.contentOverlapTopOffset) - - contentArea( - minHeight: max(0, proxy.size.height - Self.contentOverlapTopOffset) - ) + if let battle = store.battle { + GeometryReader { proxy in + ZStack(alignment: .top) { + backgroundImage(battle) + .frame(width: proxy.size.width) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Color.clear + .frame(height: Self.contentOverlapTopOffset) + + contentArea(battle) + } + .frame(width: proxy.size.width) } - .frame(width: proxy.size.width) + .scrollBounceBehavior(.basedOnSize) } - .scrollBounceBehavior(.basedOnSize) + .ignoresSafeArea(edges: .top) } - .ignoresSafeArea(edges: .top) + } else { + PreVoteSkeletonView() } } private static let contentOverlapTopOffset: CGFloat = 290 + private static let contentSectionSpacing: CGFloat = 32 + private static let optionCardHeight: CGFloat = 104 + private static let ctaHeight: CGFloat = 52 + private static let ctaBottomSpacing: CGFloat = 40 + private static let ctaHorizontalPadding: CGFloat = 20 + private static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + contentSectionSpacing } // MARK: - Background extension PreVoteView { @ViewBuilder - private var backgroundImage: some View { + private func backgroundImage(_ battle: PreVoteBattle) -> some View { ZStack { - if let urlString = store.battle.backgroundImageURL, + if let urlString = battle.backgroundImageURL, let url = URL(string: urlString) { KFImage(url) @@ -115,16 +122,26 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private var navigationBar: some View { - PickeNavigationBar( - onBack: { send(.backButtonTapped) } - ) { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .regular)) + .frame(width: 20, height: 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + Button { send(.shareTapped) } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 24, weight: .regular)) .frame(width: 24, height: 24) + .contentShape(Rectangle()) } .buttonStyle(.plain) } + .padding(.horizontal, 20) .foregroundStyle(.beige50) } } @@ -133,20 +150,15 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private func contentArea(minHeight: CGFloat) -> some View { - VStack(spacing: 40) { - contentSection - optionSection - - Spacer(minLength: 40) - - primaryButton + private func contentArea(_ battle: PreVoteBattle) -> some View { + VStack(spacing: Self.contentSectionSpacing) { + contentSection(battle) + optionSection(battle) } - .padding(.horizontal, 24) + .padding(.horizontal, 20) .padding(.top, 80) - .padding(.bottom, 40) + .padding(.bottom, Self.contentBottomSpacing) .frame(maxWidth: .infinity) - .frame(minHeight: minHeight, alignment: .top) .background( LinearGradient( stops: [ @@ -162,21 +174,21 @@ extension PreVoteView { } @ViewBuilder - private var contentSection: some View { + private func contentSection(_ battle: PreVoteBattle) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 20) { - tagsRow - titleText + tagsRow(battle) + titleText(battle) } - summaryText + summaryText(battle) } .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder - private var tagsRow: some View { + private func tagsRow(_ battle: PreVoteBattle) -> some View { HStack(spacing: 9) { - ForEach(store.battle.tags, id: \.self) { tag in + ForEach(battle.tags, id: \.self) { tag in Text(tag) .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.primary500) @@ -188,8 +200,8 @@ extension PreVoteView { } @ViewBuilder - private var titleText: some View { - Text("\(store.battle.titleLine1)\n\(store.battle.titleLine2)") + private func titleText(_ battle: PreVoteBattle) -> some View { + Text([battle.titleLine1, battle.titleLine2].filter { !$0.isEmpty }.joined(separator: "\n")) .pretendardFont(family: .Bold, size: 24) .foregroundStyle(.neutral500) .kerning(-0.6) @@ -200,8 +212,8 @@ extension PreVoteView { } @ViewBuilder - private var summaryText: some View { - Text(store.battle.summary) + private func summaryText(_ battle: PreVoteBattle) -> some View { + Text(battle.summary) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) .lineSpacing(13 * 0.4) @@ -215,11 +227,11 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var optionSection: some View { + private func optionSection(_ battle: PreVoteBattle) -> some View { ZStack { HStack(spacing: 8) { - optionCard(store.battle.leftOption) - optionCard(store.battle.rightOption) + optionCard(battle.leftOption) + optionCard(battle.rightOption) } .frame(maxWidth: .infinity) vsBadge @@ -228,13 +240,13 @@ extension PreVoteView { @ViewBuilder private func optionCard(_ option: PreVoteOption) -> some View { - let isSelected = store.selectedSide == option.philosopher + let isSelected = store.selectedOptionId == option.optionId return Button { - send(.optionTapped(option.philosopher)) + send(.optionTapped(optionId: option.optionId)) } label: { VStack(spacing: 12) { - avatarView(option.philosopher) + avatarView(imageURL: option.imageURL) VStack(spacing: 2) { Text(option.stance) @@ -245,7 +257,7 @@ extension PreVoteView { .minimumScaleFactor(0.85) .multilineTextAlignment(.center) - Text(option.philosopher.rawValue) + Text(option.representative) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) .lineLimit(1) @@ -253,7 +265,8 @@ extension PreVoteView { .multilineTextAlignment(.center) } } - .frame(maxWidth: .infinity, minHeight: 121) + .frame(maxWidth: .infinity) + .frame(height: Self.optionCardHeight) .padding(8) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( @@ -265,10 +278,15 @@ extension PreVoteView { .buttonStyle(.plain) } - private func avatarView(_ philosopher: PhilosopherAvatar) -> some View { - Image(asset: philosopher.imageAsset) + private func avatarView(imageURL: String) -> some View { + KFImage(URL(string: imageURL)) + .placeholder { + SkeletonView() + .frame(width: 28, height: 20) + } .resizable() .scaledToFit() + .frame(width: 28, height: 20) .frame(width: 40, height: 40) .background(.beige600, in: Circle()) } @@ -292,7 +310,7 @@ extension PreVoteView { CustomButton( action: { send(.primaryButtonTapped) }, title: "사전 투표하기", - config: CustomButtonConfig.primary(.large, height: 52), + config: CustomButtonConfig.primary(.large, height: Self.ctaHeight), isEnable: store.isPrimaryButtonEnabled ) } @@ -300,7 +318,7 @@ extension PreVoteView { #Preview { PreVoteView( - store: Store(initialState: PreVoteFeature.State()) { + store: Store(initialState: PreVoteFeature.State(battle: .mock)) { PreVoteFeature() } ) From 3c5d5e6a23f58169a653b45a2e8063d739cc0db8 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 05:42:27 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=8B=9C=EC=A0=90=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=B0=20=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatMessage.startTimeMs (Optional) 추가, 시나리오 매핑 시 script.startTimeMs 전달 - ChatRoomFeature.State.activeMessageId computed - currentTime(초) * 1000 이상의 startTimeMs 를 가지지 않은 마지막 메시지 id - ChatRoomView.messageList 에 ScrollViewReader + onChange(activeMessageId) - 활성 메시지가 속한 SpeakerGroup 의 마지막 id 로 scrollTo(anchor: .center) - 0.25s easeInOut 애니메이션 --- .../Entity/Sources/Home/ChatMessage.swift | 8 ++++- .../ChatRoom/Reducer/ChatRoomFeature.swift | 17 ++++++++-- .../Sources/ChatRoom/View/ChatRoomView.swift | 32 +++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift index d06a3f9..9c55d51 100644 --- a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift +++ b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift @@ -38,17 +38,23 @@ public struct ChatMessage: Equatable, Identifiable, Hashable { public let messageId: UUID public let speaker: ChatSpeaker public let text: String + /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 + /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 + /// 경우엔 nil. + public let startTimeMs: Int? public var id: UUID { messageId } public init( messageId: UUID = UUID(), speaker: ChatSpeaker, - text: String + text: String, + startTimeMs: Int? = nil ) { self.messageId = messageId self.speaker = speaker self.text = text + self.startTimeMs = startTimeMs } } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 87b99c1..970900d 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -46,14 +46,27 @@ public struct ChatRoomFeature { guard let scenario else { return bundle.messages } return scenario.nodes.flatMap { node in node.scripts.map { script in - return ChatMessage( + ChatMessage( speaker: speaker(for: script, in: scenario), - text: script.text + text: script.text, + startTimeMs: script.startTimeMs ) } } } + /// 현재 재생 시점에 해당하는 메시지 id. `currentTime` 이상의 startTimeMs 를 + /// 가지지 않은 마지막 메시지를 활성으로 본다. + public var activeMessageId: UUID? { + let currentMs = Int(currentTime * 1000) + var active: ChatMessage? + for message in messages { + guard let start = message.startTimeMs else { continue } + if start <= currentMs { active = message } else { break } + } + return active?.id ?? messages.first?.id + } + public var audioUrl: String? { guard let scenario else { return nil } if let url = scenario.audios[scenario.recommendedPathKey.rawValue] { return url } diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index b3c63b4..70d7b33 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -80,16 +80,34 @@ extension ChatRoomView { extension ChatRoomView { @ViewBuilder private func messageList() -> some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 20) { - ForEach(groupedMessages, id: \.id) { group in - messageGroup(group) + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + ForEach(groupedMessages, id: \.id) { group in + messageGroup(group) + .id(group.messages.last?.id ?? group.id) + } } + .padding(.horizontal, 16) + .padding(.vertical, 20) } - .padding(.horizontal, 16) - .padding(.vertical, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: store.activeMessageId) { _, newId in + guard let target = scrollTargetId(for: newId) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(target, anchor: .center) + } + } + } + } + + /// 활성 메시지가 속한 SpeakerGroup 의 마지막 메시지 id 로 스크롤한다. + private func scrollTargetId(for activeId: UUID?) -> UUID? { + guard let activeId else { return nil } + for group in groupedMessages where group.messages.contains(where: { $0.id == activeId }) { + return group.messages.last?.id ?? activeId } - .frame(maxWidth: .infinity, maxHeight: .infinity) + return activeId } private var groupedMessages: [SpeakerGroup] { From 0e73016cb993ee345f9e13ef4b24e5ccdebd92c3 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 05:46:21 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=A5=BC=20currentTime?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onChange(of: store.activeMessageId) 는 ObservableState 의 computed property 라 변경 감지가 안정적이지 않아 스크롤이 발생하지 않았다 - onChange(of: store.currentTime) 으로 변경, 핸들러 내부에서 activeMessageId → scrollTargetId 로 계산 후 scrollTo --- .../Home/Sources/ChatRoom/View/ChatRoomView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 70d7b33..4b5190e 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -92,8 +92,8 @@ extension ChatRoomView { .padding(.vertical, 20) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: store.activeMessageId) { _, newId in - guard let target = scrollTargetId(for: newId) else { return } + .onChange(of: store.currentTime) { _, _ in + guard let target = scrollTargetId(for: store.activeMessageId) else { return } withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } From 8dadd461f3a7b99efbac3cdef10e670c1a874072 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 05:52:03 +0900 Subject: [PATCH 26/34] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20id=20=EC=95=88=EC=A0=95=ED=99=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=A0=95=EC=83=81=ED=99=94=20+=20=EC=8B=9C=ED=82=B9=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EB=B3=B5=EC=9B=90=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatMessage.messageId 를 scriptId 기반 deterministic UUID 로 생성 - 매 렌더링마다 새 UUID 가 만들어져 ForEach id 가 흔들리고 ScrollViewReader 의 scrollTo 가 stale target 으로 무효화되던 문제 해결 - SpeakerGroup.id 를 messages.first?.id 로 도출, UUID() 매번 생성 제거 - ChatRoomFeature.canScrub 를 hasFinishedListening 게이트로 복원 - 음원 처음 한 번 완청한 뒤에만 진행바 드래그 / 15초 시킹 허용 --- .../Sources/ChatRoom/Reducer/ChatRoomFeature.swift | 10 +++++++++- .../Home/Sources/ChatRoom/View/ChatRoomView.swift | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 970900d..87103b9 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -47,6 +47,7 @@ public struct ChatRoomFeature { return scenario.nodes.flatMap { node in node.scripts.map { script in ChatMessage( + messageId: Self.scriptUUID(scriptId: script.scriptId), speaker: speaker(for: script, in: scenario), text: script.text, startTimeMs: script.startTimeMs @@ -55,6 +56,13 @@ public struct ChatRoomFeature { } } + /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 + /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). + private static func scriptUUID(scriptId: Int) -> UUID { + let hex = String(format: "%012X", scriptId) + return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() + } + /// 현재 재생 시점에 해당하는 메시지 id. `currentTime` 이상의 startTimeMs 를 /// 가지지 않은 마지막 메시지를 활성으로 본다. public var activeMessageId: UUID? { @@ -73,7 +81,7 @@ public struct ChatRoomFeature { return scenario.audios.values.first } - public var canScrub: Bool { true } + public var canScrub: Bool { hasFinishedListening } private func speaker(for script: ScenarioScript, in scenario: BattleScenario) -> ChatSpeaker { switch script.speakerType { diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 4b5190e..ec8465a 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -218,9 +218,10 @@ extension ChatRoomView { } private struct SpeakerGroup: Equatable, Identifiable { - let id = UUID() let speaker: ChatSpeaker var messages: [ChatMessage] + + var id: UUID { messages.first?.id ?? UUID() } } } From c3203bc82e6871b3b71e472a9c2a9d52c7637ab8 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 06:02:07 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20Ske?= =?UTF-8?q?leton=20Loader=20+=20=EC=98=A4=EB=94=94=EC=98=A4=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=20=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomSkeletonView (별도 파일) 추가, .pen `채팅방 - Skeleton Loader` (2CRRg) 매핑 - 좌측 메시지 그룹 / 우측 메시지 그룹 / 좌측 메시지 그룹 + 재생바 placeholder - 모든 박스를 SkeletonView 로 구성 → 좌→우 shimmer 애니메이션 자동 적용 - ChatRoomView: store.isLoadingScenario && scenario == nil 일 때 skeleton 표시 - AudioPlayerControlView 사이즈 조정 - center play/pause: 55x55 - backward / forward: 24x55 - controlColumn 에 iconSize 파라미터 추가, Image scaledToFit + contentShape --- .../Sources/ChatRoom/View/ChatRoomView.swift | 22 ++- .../Components/ChatRoomSkeletonView.swift | 128 ++++++++++++++++++ .../AudioPlayer/AudioPlayerControlView.swift | 18 ++- 3 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index ec8465a..1cc16f5 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -27,13 +27,19 @@ public struct ChatRoomView: View { } public var body: some View { - VStack(spacing: 0) { - navigationBar() - messageList() - if store.shouldShowOptions { - interactiveOptionsSection() + Group { + if shouldShowSkeleton { + ChatRoomSkeletonView() + } else { + VStack(spacing: 0) { + navigationBar() + messageList() + if store.shouldShowOptions { + interactiveOptionsSection() + } + playerBar() + } } - playerBar() } .background(Color.beige200.ignoresSafeArea()) .navigationBarHidden(true) @@ -41,6 +47,10 @@ public struct ChatRoomView: View { .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } } + + private var shouldShowSkeleton: Bool { + store.isLoadingScenario && store.scenario == nil + } } // MARK: - Navigation diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift new file mode 100644 index 0000000..836ef81 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift @@ -0,0 +1,128 @@ +// +// ChatRoomSkeletonView.swift +// Home +// +// picke.pen `채팅방 - Skeleton Loader` (2CRRg) 매핑. +// 메시지 리스트 + 재생바 placeholder 를 shimmer 애니메이션과 함께 렌더링한다. +// + +import SwiftUI + +import DesignSystem + +struct ChatRoomSkeletonView: View { + var body: some View { + VStack(spacing: 0) { + navigationBarSkeleton() + messageListSkeleton() + playerBarSkeleton() + } + .background(Color.beige50.ignoresSafeArea()) + } +} + +// MARK: - Navigation + +private extension ChatRoomSkeletonView { + @ViewBuilder + func navigationBarSkeleton() -> some View { + HStack(spacing: 12) { + SkeletonView(cornerRadius: 6) + .frame(width: 20, height: 24) + Spacer() + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 24) + } + .padding(.horizontal, 20) + .frame(height: 60) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Messages + +private extension ChatRoomSkeletonView { + @ViewBuilder + func messageListSkeleton() -> some View { + VStack(alignment: .leading, spacing: 24) { + leftGroup() + rightGroup() + leftGroup() + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + func leftGroup() -> some View { + HStack(alignment: .top, spacing: 8) { + SkeletonView(cornerRadius: 20) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 37, height: 20) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + func rightGroup() -> some View { + HStack(alignment: .top, spacing: 8) { + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 49, height: 20) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + } + SkeletonView(cornerRadius: 20) + .frame(width: 40, height: 40) + } + } +} + +// MARK: - Player bar + +private extension ChatRoomSkeletonView { + @ViewBuilder + func playerBarSkeleton() -> some View { + VStack(spacing: 16) { + SkeletonView(cornerRadius: 6) + .frame(height: 18) + + HStack(alignment: .top, spacing: 32) { + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 55) + SkeletonView(cornerRadius: 6) + .frame(width: 55, height: 55) + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 55) + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + .background(.beige50) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift index 1fd087f..1962b8c 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift @@ -40,7 +40,8 @@ public struct AudioPlayerControlView: View { Button(action: onBackward) { controlColumn( systemImage: "backward.end.fill", - iconColor: .primary800, + iconColor: .gray500, + iconSize: CGSize(width: 24, height: 55), caption: "15초" ) } @@ -52,7 +53,8 @@ public struct AudioPlayerControlView: View { Button(action: onTogglePlay) { controlColumn( systemImage: isPlaying ? "pause.fill" : "play.fill", - iconColor: .neutral900, + iconColor: .gray500, + iconSize: CGSize(width: 55, height: 55), caption: nil ) } @@ -64,7 +66,8 @@ public struct AudioPlayerControlView: View { Button(action: onForward) { controlColumn( systemImage: "forward.end.fill", - iconColor: .primary800, + iconColor: .gray500, + iconSize: CGSize(width: 24, height: 55), caption: "15초" ) } @@ -72,23 +75,26 @@ public struct AudioPlayerControlView: View { } /// 세 버튼이 동일한 baseline 으로 정렬되도록 VStack 구조 + caption 자리를 항상 확보한다. + /// `iconSize` 는 hit 영역 사이즈 — center play 는 55x55, 양옆 seek 는 24x55. @ViewBuilder private func controlColumn( systemImage: String, iconColor: Color, + iconSize: CGSize, caption: String? ) -> some View { VStack(spacing: 4) { Image(systemName: systemImage) - .font(.system(size: 28)) + .resizable() + .scaledToFit() .foregroundStyle(iconColor) - .frame(width: 40, height: 32) + .frame(width: iconSize.width, height: iconSize.height) + .contentShape(Rectangle()) Text(caption ?? " ") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral300) .opacity(caption == nil ? 0 : 1) } - .frame(width: 55) } } From e193084e80207e604cfbcf705a1f1b154d39c82e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 22:42:34 +0900 Subject: [PATCH 28/34] =?UTF-8?q?fix:=20=ED=88=AC=ED=91=9C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=B9=88=EC=B9=B8=20=EB=9D=BC=EB=B2=A8=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=98=EB=A6=BC=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoteCardView.answerSlot 의 선택 후 라벨이 고정 폭(52pt)에 묶여 잘리던 문제 해결 - 라벨 길이에 맞춰 가변 폭(fixedSize horizontal) + horizontal padding 8 - 최소 폭(minWidth 52) 은 placeholder 와 동일 유지 --- .../Home/Sources/Main/View/Components/VoteCardView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift index 9e0a202..6d3b581 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -92,13 +92,18 @@ struct VoteCardView: View { } /// 빈칸: 선택 전엔 빈 placeholder, 선택 후엔 선택된 옵션 텍스트 표시. + /// 선택된 라벨 길이에 맞춰 가변 폭 — 글자가 잘리지 않도록 horizontal padding 만 두고 + /// 최소 폭을 placeholder(52pt) 와 동일하게 유지한다. @ViewBuilder private func answerSlot() -> some View { if let label = selectedLabel { Text(label) .pretendardFont(family: .SemiBold, size: 15) .foregroundStyle(.primary500) - .frame(width: 52, height: 24) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 8) + .frame(minWidth: 52, minHeight: 24) .background(.beige200, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) From 6fb79ef0388370a97a1f8ff70929c35e966cf1f3 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 23:39:50 +0900 Subject: [PATCH 29/34] =?UTF-8?q?chore:=20Chat=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=93=9C=20+=20ChatRoom=20CustomAle?= =?UTF-8?q?rt=20/=20Skeleton=20=EC=A0=95=EB=B9=84=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModulePath.Presentations 에 Chat 케이스 추가 - Projects/Presentation/Chat 신규 모듈 (스캐폴드 단계, 본 코드 이동은 후속 커밋) - Home Project.swift 에 Presentation(.Chat) 의존 추가 - ChatRoomFeature: customAlert 통합 (CustomAlertState / CustomConfirmAlert) 진입 준비 - ChatRoomView: customAlert presentation 연결, shouldShowOptions 시각 확인용 조건 임시 완화 - PreVoteView / PreVoteSkeletonView 잔여 정리 - DesignSystem Project / Alert 디렉터리 신규 자산 --- .../TargetDependency+Module/Modules.swift | 7 +- Projects/Presentation/Chat/Project.swift | 19 ++ Projects/Presentation/Chat/Sources/Base.swift | 22 +++ .../Chat/Tests/Sources/hatTests.swift | 27 +++ Projects/Presentation/Home/Project.swift | 1 + .../ChatRoom/Reducer/ChatRoomFeature.swift | 59 +++++- .../Sources/ChatRoom/View/ChatRoomView.swift | 7 +- .../View/Components/PreVoteSkeletonView.swift | 39 ++-- .../Home/Sources/Vote/View/PreVoteView.swift | 49 ++--- Projects/Shared/DesignSystem/Project.swift | 1 + .../CustomPopup/CustomAlertModifiers.swift | 31 ++++ .../Alert/CustomPopup/CustomAlertState.swift | 81 +++++++++ .../CustomConfirmationPopupView.swift | 168 ++++++++++++++++++ 13 files changed, 450 insertions(+), 61 deletions(-) create mode 100644 Projects/Presentation/Chat/Project.swift create mode 100644 Projects/Presentation/Chat/Sources/Base.swift create mode 100644 Projects/Presentation/Chat/Tests/Sources/hatTests.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 36fed6e..b838c0a 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -21,13 +21,14 @@ public extension ModulePath { enum Presentations: String, CaseIterable { case Presentation case Splash + case Auth + case MainTab + case Home + case Chat public static let name: String = "Presentation" - case Auth - case MainTab - case Home } } diff --git a/Projects/Presentation/Chat/Project.swift b/Projects/Presentation/Chat/Project.swift new file mode 100644 index 0000000..856e8e6 --- /dev/null +++ b/Projects/Presentation/Chat/Project.swift @@ -0,0 +1,19 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Chat", + bundleId: .appBundleID(name: ".Chat"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .DesignSystem), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Chat/Sources/Base.swift b/Projects/Presentation/Chat/Sources/Base.swift new file mode 100644 index 0000000..c9504b0 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Base.swift @@ -0,0 +1,22 @@ +// +// base.swift +// DDDAttendance. +// +// Created by Roy on 2026-05-21 +// 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/Chat/Tests/Sources/hatTests.swift b/Projects/Presentation/Chat/Tests/Sources/hatTests.swift new file mode 100644 index 0000000..3133cf2 --- /dev/null +++ b/Projects/Presentation/Chat/Tests/Sources/hatTests.swift @@ -0,0 +1,27 @@ +// +// hatTests.swift +// Presentation.hatTests +// +// Created by Roy on 2026-05-21. +// + +import Testing +@testable import hat + +struct hatTests { + + @Test + func hatExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func hatLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 157e580..7490054 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -15,6 +15,7 @@ let project = Project.makeAppModule( .SPM.kingfisher, .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), + .Presentation(implements: .Chat) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 87103b9..6b28d2e 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture +import DesignSystem import DomainInterface import Entity import LogMacro @@ -27,6 +28,8 @@ public struct ChatRoomFeature { public var isLoadingScenario: Bool = false /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 public var hasFinishedListening: Bool = false + public var hasPresentedFinalVoteAlert: Bool = false + @Presents public var customAlert: CustomAlertState? /// 현재 재생 중인 시나리오 노드 id (없으면 startNodeId 폴백) public var currentNodeId: Int? @@ -132,9 +135,12 @@ public struct ChatRoomFeature { currentNode?.interactiveOptions ?? [] } - /// 현재 노드에 선택지가 있으면 선택 카드 노출 (실재생 연동 전 임시 — 항상 노출) + public var visibleOptions: [ScenarioInteractiveOption] { + interactiveOptions + } + public var shouldShowOptions: Bool { - !interactiveOptions.isEmpty + !visibleOptions.isEmpty } public var isConfirmEnabled: Bool { selectedOptionLabel != nil } @@ -149,6 +155,7 @@ public struct ChatRoomFeature { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } @@ -177,6 +184,11 @@ public struct ChatRoomFeature { case playerDurationUpdated(TimeInterval) } + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + public enum DelegateAction: Equatable { case dismiss } @@ -201,10 +213,15 @@ public struct ChatRoomFeature { handleAsyncAction(state: &state, action: asyncAction) case let .inner(innerAction): handleInnerAction(state: &state, action: innerAction) + case let .scope(scopeAction): + handleScopeAction(state: &state, action: scopeAction) case let .delegate(delegateAction): handleDelegateAction(state: &state, action: delegateAction) } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -272,7 +289,7 @@ extension ChatRoomFeature { case .confirmOptionTapped: guard let label = state.selectedOptionLabel, - let option = state.interactiveOptions.first(where: { $0.label == label }) + let option = state.visibleOptions.first(where: { $0.label == label }) else { return .none } state.currentNodeId = option.nextNodeId state.selectedOptionLabel = nil @@ -303,12 +320,14 @@ extension ChatRoomFeature { case let .loadAudio(url): state.currentTime = 0 state.playerDuration = 0 + state.isPlaying = true return .run { [player = audioPlayer] send in await player.load(url: url) let duration = await player.duration() if duration > 0 { await send(.inner(.playerDurationUpdated(duration))) } + await player.play() } case .subscribePlayer: @@ -345,9 +364,16 @@ extension ChatRoomFeature { case let .playerTimeUpdated(time): state.currentTime = time - if state.totalDuration > 0, time >= state.totalDuration - 0.5 { + if state.totalDuration > 0, + time >= state.totalDuration - 0.5, + !state.hasFinishedListening + { state.hasFinishedListening = true state.isPlaying = false + if !state.hasPresentedFinalVoteAlert { + state.hasPresentedFinalVoteAlert = true + state.customAlert = .finalVote() + } } return .none @@ -357,6 +383,31 @@ extension ChatRoomFeature { } } + 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 + return .none + case .cancelTapped: + state.customAlert = nil + state.currentTime = 0 + state.isPlaying = true + return .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + } + } + case .dismiss: + state.customAlert = nil + return .none + } + } + } + private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { switch action { case .dismiss: diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift index 1cc16f5..1ad2571 100644 --- a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift @@ -46,6 +46,7 @@ public struct ChatRoomView: View { .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } private var shouldShowSkeleton: Bool { @@ -102,8 +103,8 @@ extension ChatRoomView { .padding(.vertical, 20) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: store.currentTime) { _, _ in - guard let target = scrollTargetId(for: store.activeMessageId) else { return } + .onChange(of: store.activeMessageId) { _, activeMessageId in + guard let target = scrollTargetId(for: activeMessageId) else { return } withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } @@ -270,7 +271,7 @@ extension ChatRoomView { @ViewBuilder private func optionsList() -> some View { VStack(spacing: 9) { - ForEach(store.interactiveOptions, id: \.label) { option in + ForEach(store.visibleOptions, id: \.label) { option in optionCard(option) } } diff --git a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift index 20778f6..3b74058 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -12,8 +12,6 @@ import DesignSystem struct PreVoteSkeletonView: View { private static let designWidth: CGFloat = 375 private static let designHeight: CGFloat = 812 - private static let ctaHeight: CGFloat = 52 - private static let ctaBottomSpacing: CGFloat = 40 var body: some View { GeometryReader { proxy in @@ -22,37 +20,20 @@ struct PreVoteSkeletonView: View { ZStack(alignment: .topLeading) { Color.beige50 - // 상단 이미지 영역: PreVoteView backgroundImage 높이와 동일. - block(width: 375, height: 512, x: 0, y: 0, cornerRadius: 0) + block(width: 375, height: 329.25, x: 0, y: 0) + block(width: 375, height: 60, x: 0, y: 70) - // Navigation bar - block(width: 20, height: 10, x: 20, y: 56, cornerRadius: 2) - block(width: 24, height: 24, x: 331, y: 50, cornerRadius: 2) + block(width: 29, height: 17, x: 22, y: 360) + block(width: 49, height: 17, x: 72, y: 360) - // 태그 2개 - block(width: 29, height: 17, x: 20, y: 370) - block(width: 49, height: 17, x: 58, y: 370) + block(width: 167, height: 68, x: 16, y: 399) + block(width: 235.51, height: 61.43, x: 16, y: 479) - // 타이틀 - block(width: 210, height: 68, x: 20, y: 407) + block(width: 167, height: 105.72, x: 14.63, y: 574.04) + block(width: 167, height: 105.72, x: 193.38, y: 574.04) + block(width: 15, height: 15, x: 373.5, y: 619) - // 설명 - block(width: 265, height: 61, x: 20, y: 487) - - // 좌/우 옵션 카드 - block(width: 163.5, height: 104, x: 20, y: 580) - block(width: 163.5, height: 104, x: 191.5, y: 580) - - // VS badge - block(width: 28, height: 28, x: 173.5, y: 632, cornerRadius: 14) - - // CTA: picke.pen Button/Primary/Large 343x52, 하단 40. - block( - width: 343, - height: Self.ctaHeight, - x: 20, - y: Self.designHeight - Self.ctaBottomSpacing - Self.ctaHeight - ) + block(width: 87, height: 24, x: 144, y: 734) } .frame(width: Self.designWidth, height: Self.designHeight, alignment: .topLeading) .scaleEffect(scale, anchor: .topLeading) diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift index d289bd8..683621f 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -32,18 +32,22 @@ public struct PreVoteView: View { .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) - .overlay(alignment: .top) { - navigationBar - .background(Color.clear) - .padding(.top, 12) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .zIndex(10) - } .overlay(alignment: .bottom) { - primaryButton - .padding(.horizontal, Self.ctaHorizontalPadding) - .padding(.bottom, Self.ctaBottomSpacing) + if !shouldShowSkeleton { + primaryButton + .padding(.horizontal, Self.ctaHorizontalPadding) + .padding(.bottom, Self.ctaBottomSpacing) + } + } + .overlay(alignment: .top) { + if !shouldShowSkeleton { + navigationBar + .background(Color.clear) + .padding(.top, 12) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .zIndex(10) + } } .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in @@ -83,13 +87,14 @@ public struct PreVoteView: View { } } - private static let contentOverlapTopOffset: CGFloat = 290 - private static let contentSectionSpacing: CGFloat = 32 - private static let optionCardHeight: CGFloat = 104 + private static let contentOverlapTopOffset: CGFloat = 280 + 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 = 20 - private static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + contentSectionSpacing + private static let ctaHorizontalPadding: CGFloat = 16 + private static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing } // MARK: - Background @@ -141,7 +146,7 @@ extension PreVoteView { } .buttonStyle(.plain) } - .padding(.horizontal, 20) + .padding(.horizontal, 16) .foregroundStyle(.beige50) } } @@ -151,11 +156,11 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private func contentArea(_ battle: PreVoteBattle) -> some View { - VStack(spacing: Self.contentSectionSpacing) { + VStack(spacing: Self.contentToOptionSpacing) { contentSection(battle) optionSection(battle) } - .padding(.horizontal, 20) + .padding(.horizontal, 16) .padding(.top, 80) .padding(.bottom, Self.contentBottomSpacing) .frame(maxWidth: .infinity) @@ -251,7 +256,7 @@ extension PreVoteView { VStack(spacing: 2) { Text(option.stance) .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.neutral700) + .foregroundStyle(.neutral600) .kerning(-0.35) .lineLimit(2) .minimumScaleFactor(0.85) @@ -265,15 +270,15 @@ extension PreVoteView { .multilineTextAlignment(.center) } } + .padding(8) .frame(maxWidth: .infinity) .frame(height: Self.optionCardHeight) - .padding(8) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) .stroke(isSelected ? .beige700 : .beige500, lineWidth: 1) ) - .opacity(isSelected ? 1.0 : 0.72) + .opacity(isSelected ? 1.0 : 0.88) } .buttonStyle(.plain) } diff --git a/Projects/Shared/DesignSystem/Project.swift b/Projects/Shared/DesignSystem/Project.swift index 9363922..ade50b5 100644 --- a/Projects/Shared/DesignSystem/Project.swift +++ b/Projects/Shared/DesignSystem/Project.swift @@ -10,6 +10,7 @@ let project = Project.makeModule( product: .staticFramework, settings: .settings(), dependencies: [ + .SPM.composableArchitecture, .Shared(implements: .ThirdParty) ], sources: ["Sources/**"], diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift new file mode 100644 index 0000000..c0a8a5a --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift @@ -0,0 +1,31 @@ +// +// CustomAlertModifiers.swift +// DesignSystem +// + +import ComposableArchitecture +import SwiftUI + +public extension View { + func customAlert( + _ store: Binding, CustomAlertAction>?> + ) -> some View { + overlay { + if let alertStore = store.wrappedValue { + let alertState = alertStore.withState { $0 } + CustomConfirmationPopup( + title: alertState.title, + message: alertState.message, + confirmTitle: alertState.confirmTitle, + cancelTitle: alertState.cancelTitle, + isDestructive: alertState.isDestructive, + style: alertState.style, + onConfirm: { alertStore.send(.confirmTapped) }, + onCancel: { alertStore.send(.cancelTapped) } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: alertState.title.isEmpty == false) + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift new file mode 100644 index 0000000..11169e2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -0,0 +1,81 @@ +// +// CustomAlertState.swift +// DesignSystem +// + +import ComposableArchitecture +import SwiftUI + +@ObservableState +public struct CustomAlertState: Equatable { + public let title: String + public let message: String + public let confirmTitle: String + public let cancelTitle: String + public let isDestructive: Bool + public let style: CustomAlertStyle + + public init( + title: String, + message: String = "", + confirmTitle: String = "확인", + cancelTitle: String = "취소", + isDestructive: Bool = false, + style: CustomAlertStyle = .confirmation + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.cancelTitle = cancelTitle + self.isDestructive = isDestructive + self.style = style + } +} + +public enum CustomAlertStyle: Equatable { + case confirmation + case finalVote +} + +@CasePathable +public enum CustomAlertAction: Equatable { + case confirmTapped + case cancelTapped +} + +@Reducer +public struct CustomConfirmAlert { + public init() {} + + public var body: some Reducer, CustomAlertAction> { + EmptyReducer() + } +} + +public extension CustomAlertState where Action == CustomAlertAction { + static func alert( + title: String, + message: String = "", + confirmTitle: String = "확인", + cancelTitle: String = "취소", + isDestructive: Bool = false + ) -> CustomAlertState { + CustomAlertState( + title: title, + message: message, + confirmTitle: confirmTitle, + cancelTitle: cancelTitle, + isDestructive: isDestructive, + style: .confirmation + ) + } + + static func finalVote() -> CustomAlertState { + CustomAlertState( + title: "최종투표하고 투표 결과를 보시겠습니까?", + confirmTitle: "최종투표하기", + cancelTitle: "다시 들어볼래요", + style: .finalVote + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift new file mode 100644 index 0000000..e9f438f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -0,0 +1,168 @@ +// +// CustomConfirmationPopupView.swift +// DesignSystem +// + +import SwiftUI + +struct CustomConfirmationPopup: View { + private let title: String + private let message: String + private let confirmTitle: String + private let cancelTitle: String + private let isDestructive: Bool + private let style: CustomAlertStyle + private let onConfirm: () -> Void + private let onCancel: () -> Void + + @State private var isContentVisible = false + + init( + title: String, + message: String, + confirmTitle: String, + cancelTitle: String, + isDestructive: Bool, + style: CustomAlertStyle, + onConfirm: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.cancelTitle = cancelTitle + self.isDestructive = isDestructive + self.style = style + self.onConfirm = onConfirm + self.onCancel = onCancel + } + + var body: some View { + ZStack { + Color.black + .opacity(isContentVisible ? 0.6 : 0) + .ignoresSafeArea() + .onTapGesture(perform: onCancel) + + popupContent + .padding(.horizontal, 20) + .offset(y: isContentVisible ? 0 : 120) + .opacity(isContentVisible ? 1 : 0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.3)) { + isContentVisible = true + } + } + } + + @ViewBuilder + private var popupContent: some View { + switch style { + case .confirmation: + confirmationContent + case .finalVote: + finalVoteContent + } + } + + private var confirmationContent: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(family: .Bold, size: 18) + .foregroundStyle(.neutral800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + Text(message) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .multilineTextAlignment(.center) + } + } + + HStack(spacing: 8) { + if !cancelTitle.isEmpty { + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } + + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + isDestructive ? Color.errorDefault : Color.primary500, + in: RoundedRectangle(cornerRadius: 2) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 28) + .padding(.horizontal, 20) + .frame(width: 320) + .background(ComponentToken.Popup.background, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(ComponentToken.Popup.border, lineWidth: 1) + ) + .onTapGesture {} + } + + private var finalVoteContent: 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) + .padding(.vertical, 20) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text(cancelTitle) + .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: onConfirm) { + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(0.9) + .onTapGesture {} + } +} From fafb75aff47f05da75db9a4e2e9bd5cf721d91e4 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 23:44:27 +0900 Subject: [PATCH 30/34] =?UTF-8?q?refactor:=20ChatRoom=20=EC=9D=84=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20Chat=20=EB=AA=A8=EB=93=88=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20ChatCoordinator=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projects/Presentation/Chat 신규 모듈 - ChatCoordinator (Flow) + ChatCoordinatorView 추가 - ChatRoomFeature / ChatRoomView / ChatRoomSkeletonView 를 Home 에서 이동 - 의존: ComposableArchitecture, TCAFlow, Kingfisher, LogMacro, Domain/UseCase, Shared/DesignSystem - HomeCoordinator - HomeScreen.chatRoom(ChatRoomFeature) 제거, chat(ChatCoordinator) 추가 - preVote.voteSubmitted 시 .chat(.init(battleId:)) push - chat 의 delegate.dismiss 수신 시 backAction - HomeCoordinatorView: chat 케이스에서 ChatCoordinatorView 사용 - swift format 친화적으로 모든 switch 분기에 explicit return 유지 --- Projects/Presentation/Chat/Project.swift | 6 +- .../ChatRoom/Reducer/ChatRoomFeature.swift | 0 .../Sources/ChatRoom/View/ChatRoomView.swift | 0 .../Components/ChatRoomSkeletonView.swift | 0 .../Coordinator/Reducer/ChatCoordinator.swift | 113 ++++++++++++++++++ .../View/ChatCoordinatorView.swift | 29 +++++ .../Coordinator/Reducer/HomeCoordinator.swift | 7 +- .../View/HomeCoordinatorView.swift | 5 +- 8 files changed, 153 insertions(+), 7 deletions(-) rename Projects/Presentation/{Home => Chat}/Sources/ChatRoom/Reducer/ChatRoomFeature.swift (100%) rename Projects/Presentation/{Home => Chat}/Sources/ChatRoom/View/ChatRoomView.swift (100%) rename Projects/Presentation/{Home => Chat}/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift (100%) create mode 100644 Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift create mode 100644 Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift diff --git a/Projects/Presentation/Chat/Project.swift b/Projects/Presentation/Chat/Project.swift index 856e8e6..8a84566 100644 --- a/Projects/Presentation/Chat/Project.swift +++ b/Projects/Presentation/Chat/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "Chat", @@ -14,6 +14,8 @@ let project = Project.makeAppModule( .Shared(implements: .DesignSystem), .SPM.composableArchitecture, .SPM.tcaFlow, + .SPM.kingfisher, + .SPM.logMarco, ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift similarity index 100% rename from Projects/Presentation/Home/Sources/ChatRoom/Reducer/ChatRoomFeature.swift rename to Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/ChatRoom/View/ChatRoomView.swift rename to Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift diff --git a/Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift rename to Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift new file mode 100644 index 0000000..811d5ef --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -0,0 +1,113 @@ +// +// ChatCoordinator.swift +// Chat +// +// 채팅방 모듈 진입점. battleId 를 받아 ChatRoomFeature 를 root 로 띄운다. +// 향후 사후 투표 결과 / 공유 등 후속 화면이 필요해지면 ChatScreen enum 에 case 만 추가. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "ChatScreen", navigation: true) +public struct ChatCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init(battleId: Int = 0) { + routes = [.root(.chatRoom(.init(battleId: battleId)), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + public enum DelegateAction: Equatable { + case dismiss + } + + func handleRoute(state: inout State, action: Action) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } +} + +extension ChatCoordinator { + private func routerAction( + state _: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): + .send(.delegate(.dismiss)) + default: + .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension ChatCoordinator { + @Reducer + public enum ChatScreen { + case chatRoom(ChatRoomFeature) + } +} + +// swiftformat:enable extensionAccessControl + +extension ChatCoordinator.ChatScreen.State: Equatable {} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift new file mode 100644 index 0000000..be6f777 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -0,0 +1,29 @@ +// +// ChatCoordinatorView.swift +// Chat +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct ChatCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .chatRoom(chatRoomStore): + ChatRoomView(store: chatRoomStore) + .toolbar(.hidden, for: .tabBar) + } + } + } +} diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 8dfca2e..c44b1cd 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -7,6 +7,7 @@ import Foundation +import Chat import ComposableArchitecture import TCAFlow @@ -68,10 +69,10 @@ extension HomeCoordinator { return .send(.view(.backAction)) case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, _)))): - state.routes.push(.chatRoom(.init(battleId: battleId))) + state.routes.push(.chat(.init(battleId: battleId))) return .none - case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): + case .routeAction(_, action: .chat(.delegate(.dismiss))): return .send(.view(.backAction)) default: @@ -100,7 +101,7 @@ extension HomeCoordinator { public enum HomeScreen { case home(HomeFeature) case preVote(PreVoteFeature) - case chatRoom(ChatRoomFeature) + case chat(ChatCoordinator) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index ea1b80c..f11c8a1 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +import Chat import ComposableArchitecture import TCAFlow @@ -27,8 +28,8 @@ public struct HomeCoordinatorView: View { case let .preVote(preVoteStore): PreVoteView(store: preVoteStore) .toolbar(.hidden, for: .tabBar) - case let .chatRoom(chatRoomStore): - ChatRoomView(store: chatRoomStore) + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) .toolbar(.hidden, for: .tabBar) } } From 8dccd5daacfb351ee76eb53fd9f37666b7c67fce Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 23:50:24 +0900 Subject: [PATCH 31/34] =?UTF-8?q?refactor:=20PreVote=20=EB=8F=84=20Chat=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=ED=9D=A1=EC=88=98=20+=20ChatCoor?= =?UTF-8?q?dinator=20=EA=B0=80=20PreVote=E2=86=92ChatRoom=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vote 디렉터리 (PreVoteFeature / PreVoteView / Skeleton) Home → Chat 으로 이동 - ChatCoordinator - root 를 .preVote 로 변경 (battleId 전달) - preVote.voteSubmitted 수신 시 chatRoom push - preVote.dismiss → delegate.dismiss 로 외부에 전파 - chatRoom.dismiss → 자체 goBack - ChatCoordinatorView: preVote / chatRoom 두 케이스 모두 렌더 - HomeCoordinator - HomeScreen 에서 preVote 케이스 제거 (단일 chat 진입점) - presentPreVote(battleId) 수신 시 chat 만 push - 모든 switch 에 explicit return 유지 --- Projects/Presentation/Chat/Sources/Base.swift | 22 ------------------- .../Coordinator/Reducer/ChatCoordinator.swift | 21 +++++++++++++----- .../View/ChatCoordinatorView.swift | 3 +++ .../Sources/Vote/Reducer/PreVoteFeature.swift | 0 .../View/Components/PreVoteSkeletonView.swift | 0 .../Sources/Vote/View/PreVoteView.swift | 0 .../Coordinator/Reducer/HomeCoordinator.swift | 8 ------- .../View/HomeCoordinatorView.swift | 3 --- 8 files changed, 18 insertions(+), 39 deletions(-) delete mode 100644 Projects/Presentation/Chat/Sources/Base.swift rename Projects/Presentation/{Home => Chat}/Sources/Vote/Reducer/PreVoteFeature.swift (100%) rename Projects/Presentation/{Home => Chat}/Sources/Vote/View/Components/PreVoteSkeletonView.swift (100%) rename Projects/Presentation/{Home => Chat}/Sources/Vote/View/PreVoteView.swift (100%) diff --git a/Projects/Presentation/Chat/Sources/Base.swift b/Projects/Presentation/Chat/Sources/Base.swift deleted file mode 100644 index c9504b0..0000000 --- a/Projects/Presentation/Chat/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2026-05-21 -// 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/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 811d5ef..746a8e6 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -2,7 +2,7 @@ // ChatCoordinator.swift // Chat // -// 채팅방 모듈 진입점. battleId 를 받아 ChatRoomFeature 를 root 로 띄운다. +// 채팅방 모듈 진입점. battleId 를 받아 PreVote → ChatRoom 흐름을 자체적으로 라우팅한다. // 향후 사후 투표 결과 / 공유 등 후속 화면이 필요해지면 ChatScreen enum 에 case 만 추가. // @@ -20,7 +20,7 @@ public struct ChatCoordinator { public var routes: [Route] public init(battleId: Int = 0) { - routes = [.root(.chatRoom(.init(battleId: battleId)), embedInNavigationView: true)] + routes = [.root(.preVote(.init(battleId: battleId)), embedInNavigationView: true)] } } @@ -64,14 +64,22 @@ public struct ChatCoordinator { extension ChatCoordinator { private func routerAction( - state _: inout State, + state: inout State, action: IndexedRouterActionOf ) -> Effect { switch action { + 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))) + return .none + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): - .send(.delegate(.dismiss)) + return .send(.view(.backAction)) + default: - .none + return .none } } @@ -95,7 +103,7 @@ extension ChatCoordinator { ) -> Effect { switch action { case .dismiss: - .none + return .none } } } @@ -104,6 +112,7 @@ extension ChatCoordinator { extension ChatCoordinator { @Reducer public enum ChatScreen { + case preVote(PreVoteFeature) case chatRoom(ChatRoomFeature) } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift index be6f777..e724ee2 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -20,6 +20,9 @@ public struct ChatCoordinatorView: View { public var body: some View { TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in switch screen.case { + case let .preVote(preVoteStore): + PreVoteView(store: preVoteStore) + .toolbar(.hidden, for: .tabBar) case let .chatRoom(chatRoomStore): ChatRoomView(store: chatRoomStore) .toolbar(.hidden, for: .tabBar) diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift rename to Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift diff --git a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift rename to Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift similarity index 100% rename from Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift rename to Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index c44b1cd..7feaaeb 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -62,13 +62,6 @@ extension HomeCoordinator { ) -> Effect { switch action { case let .routeAction(_, action: .home(.delegate(.presentPreVote(battleId)))): - state.routes.push(.preVote(.init(battleId: battleId))) - return .none - - case .routeAction(_, action: .preVote(.delegate(.dismiss))): - return .send(.view(.backAction)) - - case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, _)))): state.routes.push(.chat(.init(battleId: battleId))) return .none @@ -100,7 +93,6 @@ extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) - case preVote(PreVoteFeature) case chat(ChatCoordinator) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index f11c8a1..6b728fb 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -25,9 +25,6 @@ public struct HomeCoordinatorView: View { switch screen.case { case let .home(homeStore): HomeView(store: homeStore) - case let .preVote(preVoteStore): - PreVoteView(store: preVoteStore) - .toolbar(.hidden, for: .tabBar) case let .chat(chatStore): ChatCoordinatorView(store: chatStore) .toolbar(.hidden, for: .tabBar) From ad0a8656bb85a55e331189462aac64f63d5dd211 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 21 May 2026 23:58:39 +0900 Subject: [PATCH 32/34] =?UTF-8?q?chore:=20PreVote=20Skeleton=20=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=20=EB=8B=A8=EC=9C=84=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20+=20SwiftUI=20Expert=20Skill=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PreVoteSkeletonView - GeometryReader + 절대좌표(offset x/y) ZStack 매핑 제거 - hero / contentSection(tagsRow·titleBlock·summaryBlock·optionsRow) / ctaButton 의 의미적 VStack 으로 재구성 - SkeletonView shimmer 는 그대로 유지 (모든 박스에 자동 적용) - AGENTS.md `지원 스킬 목록` 에 SwiftUI Expert Skill 항목 추가 - 출처(AvdLee/SwiftUI-Agent-Skill) / 로컬 설치 경로 (Claude Code, Codex) - 재설치·업데이트 명령 / 호출 시점 가이드 --- AGENTS.md | 15 +++ .../View/Components/PreVoteSkeletonView.swift | 113 ++++++++++++------ 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f6abc7..119f4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -751,6 +751,21 @@ tuist generate --no-open --path Projects/Shared/DesignSystem - `@swiftui-uikit-interop` — SwiftUI ↔ UIKit 상호 운용성 전문 - `@swift-concurrency` — Swift 6 Concurrency 및 async/await 전문 +### SwiftUI 전문 가이드 스킬 — `swiftui-expert-skill` +- **출처**: [AvdLee/SwiftUI-Agent-Skill](https://github.com/AvdLee/SwiftUI-Agent-Skill) (Agent Skills 오픈 포맷) +- **로컬 설치 위치** + - Claude Code: `~/.claude/plugins/SwiftUI-Agent-Skill/` + - Codex: `~/.codex/skills/swiftui-expert-skill/` + - Cursor 도 동일 폴더를 `Plugins` 가이드대로 등록하면 됨 +- **재설치 / 업데이트** + ```bash + rm -rf ~/.claude/plugins/SwiftUI-Agent-Skill ~/.codex/skills/swiftui-expert-skill + git clone https://github.com/AvdLee/SwiftUI-Agent-Skill.git ~/.claude/plugins/SwiftUI-Agent-Skill + cp -R ~/.claude/plugins/SwiftUI-Agent-Skill/swiftui-expert-skill ~/.codex/skills/swiftui-expert-skill + ``` +- **언제 호출**: SwiftUI 상태관리(`@Observable` / 프로퍼티 래퍼 선택), 뷰 컴포지션, 리스트·내비게이션·시트, Swift Charts, 애니메이션, macOS multi-window, iOS 26+ Liquid Glass, 접근성, Instruments 트레이스 분석. +- **호출 방법**: 프롬프트에 *"swiftui-expert skill 을 사용해 ..."* 형태로 지시하거나, `.trace` 경로/녹화 요청처럼 트리거 키워드가 들어오면 자동 활성화. + ### 자동 호출 키워드 다음 키워드 언급 시 **자동으로 성능 최적화 스킬 호출**: - `ifCaseLet`, `TCA`, `Effect`, `메모리 누수`, `성능`, `최적화` diff --git a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift index 3b74058..e66d811 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -1,8 +1,9 @@ // // PreVoteSkeletonView.swift -// Home +// Chat // // PreVoteView 의 로딩 상태 placeholder. +// .pen `사전 투표창 - Skeleton Loader` 를 의미 단위(hero / 카피 / 옵션 / CTA) 로 재구성한다. // import SwiftUI @@ -10,52 +11,92 @@ import SwiftUI import DesignSystem struct PreVoteSkeletonView: View { - private static let designWidth: CGFloat = 375 - private static let designHeight: CGFloat = 812 - var body: some View { - GeometryReader { proxy in - let scale = proxy.size.width / Self.designWidth + VStack(spacing: 0) { + hero + contentSection + Spacer(minLength: 0) + ctaButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.beige50.ignoresSafeArea()) + } +} - ZStack(alignment: .topLeading) { - Color.beige50 +// MARK: - Hero - block(width: 375, height: 329.25, x: 0, y: 0) - block(width: 375, height: 60, x: 0, y: 70) +private extension PreVoteSkeletonView { + @ViewBuilder + var hero: some View { + SkeletonView(cornerRadius: 6) + .frame(height: 329) + .overlay(alignment: .top) { + SkeletonView(cornerRadius: 6) + .frame(height: 60) + .padding(.top, 70) + } + } +} + +// MARK: - Content + +private extension PreVoteSkeletonView { + @ViewBuilder + var contentSection: some View { + VStack(alignment: .leading, spacing: 16) { + tagsRow + titleBlock + summaryBlock + optionsRow + } + .padding(.horizontal, 16) + .padding(.top, 24) + } - block(width: 29, height: 17, x: 22, y: 360) - block(width: 49, height: 17, x: 72, y: 360) + @ViewBuilder + var tagsRow: some View { + HStack(spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 29, height: 17) + SkeletonView(cornerRadius: 6) + .frame(width: 49, height: 17) + } + } - block(width: 167, height: 68, x: 16, y: 399) - block(width: 235.51, height: 61.43, x: 16, y: 479) + @ViewBuilder + var titleBlock: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 167, height: 68) + } - block(width: 167, height: 105.72, x: 14.63, y: 574.04) - block(width: 167, height: 105.72, x: 193.38, y: 574.04) - block(width: 15, height: 15, x: 373.5, y: 619) + @ViewBuilder + var summaryBlock: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 235.5, height: 61.43) + } - block(width: 87, height: 24, x: 144, y: 734) + @ViewBuilder + var optionsRow: some View { + ZStack { + HStack(spacing: 8) { + SkeletonView(cornerRadius: 6) + SkeletonView(cornerRadius: 6) } - .frame(width: Self.designWidth, height: Self.designHeight, alignment: .topLeading) - .scaleEffect(scale, anchor: .topLeading) - .frame( - width: proxy.size.width, - height: Self.designHeight * scale, - alignment: .topLeading - ) + .frame(height: 105.72) + + SkeletonView(cornerRadius: 7.5) + .frame(width: 15, height: 15) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } +} + +// MARK: - CTA +private extension PreVoteSkeletonView { @ViewBuilder - private func block( - width: CGFloat, - height: CGFloat, - x: CGFloat, - y: CGFloat, - cornerRadius: CGFloat = 6 - ) -> some View { - SkeletonView(cornerRadius: cornerRadius) - .frame(width: width, height: height) - .offset(x: x, y: y) + var ctaButton: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 87, height: 24) + .padding(.bottom, 40) } } From 35154bad27d52ae850372f31b0800043a6a58fd0 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 00:07:18 +0900 Subject: [PATCH 33/34] =?UTF-8?q?fix(ci):=20Gemini=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?actions/github-script=20SyntaxError=20=ED=95=B4=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prompt 빌드를 외부 template literal 에서 string array + .join('\n') 로 변경 annotated_diff 안에 backtick / \${} 가 포함되면 template literal 이 깨져 SyntaxError: Invalid or unexpected token 이 발생하던 문제 제거 - ```suggestion 같은 triple-backtick 표현은 const TRIPLE 로 분리 후 concat - pr_title / pr_body / annotated_diff 모두 string concat 으로 안전하게 결합 --- .github/workflows/gemini-code-review.yml | 134 ++++++++++++----------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/.github/workflows/gemini-code-review.yml b/.github/workflows/gemini-code-review.yml index 59e114d..4df9541 100644 --- a/.github/workflows/gemini-code-review.yml +++ b/.github/workflows/gemini-code-review.yml @@ -119,71 +119,75 @@ jobs: responseMimeType: "application/json", }, }); - const prompt = `You are a senior iOS engineer performing a code review on a Swift 6 / SwiftUI / TCA 1.25 multi-module Clean Architecture project built with Tuist 4. - - [Tone and Style Guidelines] - - Do NOT include unnecessary praise, greetings, or overly verbose explanations. - - Do NOT provide unsolicited CS insights (컴퓨터 과학적 통찰) or related interview questions. - - Provide concise, objective, and well-organized feedback suitable for immediate practical use. - - CRITICAL LINE NUMBER RULES (MUST FOLLOW): - - Each diff line is annotated with [LINE N] for added lines or [CTX N] for context lines. - - You MUST only comment on [LINE N] lines (added/modified code). NEVER comment on [CTX] or [DEL] lines. - - Use the EXACT number N shown in the [LINE N] annotation. Do NOT compute line numbers yourself. - - The "code_snippet" field must contain the actual code from that [LINE N] line. - - [PR Context] - Title: ${pr_title} - Description: ${pr_body} - - [Project Architecture] - - Stack: Swift 6, SwiftUI, TCA 1.25, Tuist 4 (multi-module) - - Layer dependency: Presentation → Domain ← Data, Network is only referenced by Data - - Deployment target: iOS 26.0 (iPhone only) - - Source roots: Projects/App, Projects/Presentation, Projects/Domain/{Entity,UseCase,DomainInterface,DataInterface}, Projects/Data/{Model,Repository,API,Service}, Projects/Network/*, Projects/Shared/* - - [Review Criteria] - 1. TCA Convention: Verify @Reducer + @ObservableState usage; Action naming describes events that occurred (e.g., xxxButtonTapped, xxxResponse), NOT intended effects (e.g., performLogin, loadData); Effect is .none when no side effect and .run for async work; shared logic lives in private methods, NOT shared Actions; Effect.run must NOT capture entire @ObservableState (extract needed values first); Reducer must NOT perform CPU-intensive work (offload to Effect); Store.scope must use stored property paths only (no computed transforms); Navigation uses @Reducer enum; transient UI state (hover, focus, animation) stays in SwiftUI @State, not TCA State. - 2. Module Architecture: Respect Presentation → Domain ← Data dependency direction; Network is only imported by Data; module boundaries expose protocols (DomainInterface / DataInterface); DTO-to-Entity mapping stays in Data layer. - 3. SwiftUI Convention (이 프로젝트는 AGENTS.md 규칙을 따른다 — SubView 분리는 `@ViewBuilder private func` 또는 `private var` 형태가 정식이며 위반이 아니다. SubView 를 struct 로 강제하는 의견은 절대 내지 말 것); use @Binding when a SubView mutates parent @State; no "View" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions. - 4. Swift Code Quality: guard early return with shorthand optional binding (guard let value else { ... }) followed by a blank line; final class by default; private first (avoid fileprivate unless required); never force unwrap; operator line break puts operator at the start of the next line; function params line-break with closing paren on its own line; ternary for simple return/assignment only, split on '?'; [weak self] + guard let self else { return } in closures; constant groups as private enum (Metric/Font/Constant), NOT struct; empty collection literals ([] / [:]); indent 4 spaces; 120-char line limit. - 5. Actionable Feedback: When improvement is needed you MUST provide a concrete Swift fix using GitHub's \`\`\`suggestion block. - - [Severity Prefix] - Each comment body MUST start with a severity tag on its own line, then a blank line, then the actual comment: - - 🔴 [P1] Critical: force-unwrap crash risk, retain cycles / memory leaks, heavy or blocking work inside a Reducer, main-thread blocking - - 🟠 [P2] Major: module dependency-direction violations (e.g., Domain importing Data), Effect.run capturing entire @ObservableState, sharing logic through Actions, Store.scope with computed property, serious concurrency or error-mapping issues - - 🟡 [P3] Minor: Action naming that describes intent/effect (performLogin, loadData, setRecords), SubView written as @ViewBuilder function, Swift API Design Guideline violations on public APIs, inefficient Effect composition - - 🔵 [P4] Readability: View-suffix naming, Spacer() misuse, missing final / private, guard / ternary / line-break style violations, constant groups declared as struct instead of enum - (P5 Nitpick 등급은 사용하지 않는다. 빈 줄·공백·trailing comma·주석 줄 같은 포맷 의견은 절대 만들지 말 것.) - - Format: "🔴 **[P1] Critical**\\n\\nActual comment content here..." - - Ignore the following entirely — these are NOT review issues for this project: - - blank lines, whitespace, trailing commas, comment-only lines (P5 nitpicks) - - SubView 를 struct 로 분리하라는 의견 (이 프로젝트는 AGENTS.md 의 `@ViewBuilder` 패턴이 정식) - - import 순서, 줄 단위 들여쓰기 - - `description` 같은 BaseTargetType 의 정식 프로퍼티 네이밍 의견 - - Only report substantive issues — bugs, architecture violations, security/concurrency risks. Do NOT generate P5 nitpicks. Write all review comments in Korean using Markdown, without greetings or closings. - - Respond ONLY with a JSON object in this exact format: - { - "summary": "전체 리뷰 요약 (한국어, 마크다운)", - "comments": [ - { - "path": "file path relative to repo root (from the b/ prefix in diff)", - "line": , - "code_snippet": "the actual code content from that line", - "body": "🔴/🟠/🟡/🔵 **[P1~P4] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 \\\`\\\`\\\`suggestion 블록 포함)" - } - ] - } - If no issues are found, return {"summary": "...", "comments": []}. - - - ${annotated_diff} - `; + const TRIPLE = "```"; + const promptLines = [ + "You are a senior iOS engineer performing a code review on a Swift 6 / SwiftUI / TCA 1.25 multi-module Clean Architecture project built with Tuist 4.", + "", + "[Tone and Style Guidelines]", + "- Do NOT include unnecessary praise, greetings, or overly verbose explanations.", + "- Do NOT provide unsolicited CS insights (컴퓨터 과학적 통찰) or related interview questions.", + "- Provide concise, objective, and well-organized feedback suitable for immediate practical use.", + "", + "CRITICAL LINE NUMBER RULES (MUST FOLLOW):", + "- Each diff line is annotated with [LINE N] for added lines or [CTX N] for context lines.", + "- You MUST only comment on [LINE N] lines (added/modified code). NEVER comment on [CTX] or [DEL] lines.", + "- Use the EXACT number N shown in the [LINE N] annotation. Do NOT compute line numbers yourself.", + "- The \"code_snippet\" field must contain the actual code from that [LINE N] line.", + "", + "[PR Context]", + "Title: " + pr_title, + "Description: " + pr_body, + "", + "[Project Architecture]", + "- Stack: Swift 6, SwiftUI, TCA 1.25, Tuist 4 (multi-module)", + "- Layer dependency: Presentation → Domain ← Data, Network is only referenced by Data", + "- Deployment target: iOS 26.0 (iPhone only)", + "- Source roots: Projects/App, Projects/Presentation, Projects/Domain/{Entity,UseCase,DomainInterface,DataInterface}, Projects/Data/{Model,Repository,API,Service}, Projects/Network/*, Projects/Shared/*", + "", + "[Review Criteria]", + "1. TCA Convention: Verify @Reducer + @ObservableState usage; Action naming describes events that occurred (e.g., xxxButtonTapped, xxxResponse), NOT intended effects (e.g., performLogin, loadData); Effect is .none when no side effect and .run for async work; shared logic lives in private methods, NOT shared Actions; Effect.run must NOT capture entire @ObservableState (extract needed values first); Reducer must NOT perform CPU-intensive work (offload to Effect); Store.scope must use stored property paths only (no computed transforms); Navigation uses @Reducer enum; transient UI state (hover, focus, animation) stays in SwiftUI @State, not TCA State.", + "2. Module Architecture: Respect Presentation → Domain ← Data dependency direction; Network is only imported by Data; module boundaries expose protocols (DomainInterface / DataInterface); DTO-to-Entity mapping stays in Data layer.", + "3. SwiftUI Convention (이 프로젝트는 AGENTS.md 규칙을 따른다 — SubView 분리는 @ViewBuilder private func 또는 private var 형태가 정식이며 위반이 아니다. SubView 를 struct 로 강제하는 의견은 절대 내지 말 것); use @Binding when a SubView mutates parent @State; no \"View\" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions.", + "4. Swift Code Quality: guard early return with shorthand optional binding (guard let value else { ... }) followed by a blank line; final class by default; private first (avoid fileprivate unless required); never force unwrap; operator line break puts operator at the start of the next line; function params line-break with closing paren on its own line; ternary for simple return/assignment only, split on '?'; [weak self] + guard let self else { return } in closures; constant groups as private enum (Metric/Font/Constant), NOT struct; empty collection literals ([] / [:]); indent 4 spaces; 120-char line limit.", + "5. Actionable Feedback: When improvement is needed you MUST provide a concrete Swift fix using GitHub's " + TRIPLE + "suggestion block.", + "", + "[Severity Prefix]", + "Each comment body MUST start with a severity tag on its own line, then a blank line, then the actual comment:", + "- 🔴 [P1] Critical: force-unwrap crash risk, retain cycles / memory leaks, heavy or blocking work inside a Reducer, main-thread blocking", + "- 🟠 [P2] Major: module dependency-direction violations (e.g., Domain importing Data), Effect.run capturing entire @ObservableState, sharing logic through Actions, Store.scope with computed property, serious concurrency or error-mapping issues", + "- 🟡 [P3] Minor: Action naming that describes intent/effect (performLogin, loadData, setRecords), SubView written as @ViewBuilder function, Swift API Design Guideline violations on public APIs, inefficient Effect composition", + "- 🔵 [P4] Readability: View-suffix naming, Spacer() misuse, missing final / private, guard / ternary / line-break style violations, constant groups declared as struct instead of enum", + "(P5 Nitpick 등급은 사용하지 않는다. 빈 줄·공백·trailing comma·주석 줄 같은 포맷 의견은 절대 만들지 말 것.)", + "", + "Format: \"🔴 **[P1] Critical**\\n\\nActual comment content here...\"", + "", + "Ignore the following entirely — these are NOT review issues for this project:", + "- blank lines, whitespace, trailing commas, comment-only lines (P5 nitpicks)", + "- SubView 를 struct 로 분리하라는 의견 (이 프로젝트는 AGENTS.md 의 @ViewBuilder 패턴이 정식)", + "- import 순서, 줄 단위 들여쓰기", + "- description 같은 BaseTargetType 의 정식 프로퍼티 네이밍 의견", + "", + "Only report substantive issues — bugs, architecture violations, security/concurrency risks. Do NOT generate P5 nitpicks. Write all review comments in Korean using Markdown, without greetings or closings.", + "", + "Respond ONLY with a JSON object in this exact format:", + "{", + " \"summary\": \"전체 리뷰 요약 (한국어, 마크다운)\",", + " \"comments\": [", + " {", + " \"path\": \"file path relative to repo root (from the b/ prefix in diff)\",", + " \"line\": ,", + " \"code_snippet\": \"the actual code content from that line\",", + " \"body\": \"🔴/🟠/🟡/🔵 **[P1~P4] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 " + TRIPLE + "suggestion 블록 포함)\"", + " }", + " ]", + "}", + "If no issues are found, return {\"summary\": \"...\", \"comments\": []}.", + "", + "", + annotated_diff, + "" + ]; + const prompt = promptLines.join("\n"); const result = await model.generateContent(prompt); const text = result.response.text(); fs.writeFileSync("review_result.json", text); From a3fbe3133c1a5c26b292fde8542cdd95f278285c Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 22 May 2026 00:12:19 +0900 Subject: [PATCH 34/34] =?UTF-8?q?fix(test):=20Chat=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20/=20import?= =?UTF-8?q?=20/=20struct=20=EB=AA=85=20=EC=98=A4=ED=83=80=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hatTests.swift → ChatTests.swift 로 rename - @testable import hat → @testable import Chat - struct hatTests → struct ChatTests, 함수명도 chatExample / chatLogicTest 로 일관화 --- .../Chat/Tests/Sources/ChatTests.swift | 24 +++++++++++++++++ .../Chat/Tests/Sources/hatTests.swift | 27 ------------------- 2 files changed, 24 insertions(+), 27 deletions(-) create mode 100644 Projects/Presentation/Chat/Tests/Sources/ChatTests.swift delete mode 100644 Projects/Presentation/Chat/Tests/Sources/hatTests.swift diff --git a/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift b/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift new file mode 100644 index 0000000..206e298 --- /dev/null +++ b/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift @@ -0,0 +1,24 @@ +// +// ChatTests.swift +// Presentation.ChatTests +// +// Created by Roy on 2026-05-21. +// + +@testable import Chat +import Testing + +struct ChatTests { + @Test + func chatExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func chatLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } +} diff --git a/Projects/Presentation/Chat/Tests/Sources/hatTests.swift b/Projects/Presentation/Chat/Tests/Sources/hatTests.swift deleted file mode 100644 index 3133cf2..0000000 --- a/Projects/Presentation/Chat/Tests/Sources/hatTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// hatTests.swift -// Presentation.hatTests -// -// Created by Roy on 2026-05-21. -// - -import Testing -@testable import hat - -struct hatTests { - - @Test - func hatExample() { - // This is an example of a test case. - #expect(true) - } - - @Test - func hatLogicTest() { - // Add your test logic here. - let result = true - #expect(result == true) - } - -} -