From bf34ab502524a8b74901f03804ea45fc13d16064 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 12:10:49 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=8C=80=EC=82=AC=20=EC=98=A4=EB=94=94=EC=98=A4=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=8B=9C=EA=B0=84=20=EC=8B=B1=ED=81=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=AA=A9=EC=86=8C=EB=A6=AC=20=EB=82=98=EC=98=AC=20=EB=95=8C=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=EC=B0=BD=20=ED=95=9C=20=EC=A4=84=EC=94=A9=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 0a78c8a2..7eed6e44 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -55,9 +55,13 @@ public struct ChatRoomFeature { public var messages: [ChatMessage] { guard let scenario else { return bundle.messages } let nodes = visibleNodes(in: scenario) + let currentMs = Int(currentTime * 1000) return nodes.flatMap { node in - node.scripts.map { script in - ChatMessage( + node.scripts.compactMap { script -> ChatMessage? in + // 오디오가 해당 대사 시작 시점에 도달한 것만 노출 → 목소리 나올 때 대화창 한 줄씩. + // (재진입/완청 후에도 동일하게 현재 재생 위치 기준으로 싱크) + guard script.startTimeMs <= currentMs else { return nil } + return ChatMessage( messageId: Self.scriptUUID(scriptId: script.scriptId), speaker: speaker(for: script, in: scenario), text: script.text, From 1ada094bee54ffeeee7e4213885e2d916e18eebe Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:15:37 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=A5=EB=B3=84=20=EB=A7=90=ED=92=8D=EC=84=A0=20?= =?UTF-8?q?=EC=9D=8C=EC=84=B1=20=EC=8B=B1=ED=81=AC(=EA=B8=80=EC=9E=90?= =?UTF-8?q?=EC=88=98=20=EB=B9=84=EB=A1=80)=20+=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=A4=91=20=EB=B2=84=EB=B8=94=20=EA=B0=95=EC=A1=B0/=ED=8C=8C?= =?UTF-8?q?=ED=98=95=20+=20=EC=B6=9C=EB=A0=81=EC=9E=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 103 ++++++++++++++---- .../Sources/ChatRoom/View/ChatRoomView.swift | 40 +++++-- 2 files changed, 111 insertions(+), 32 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 7eed6e44..8ad52784 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -56,38 +56,97 @@ public struct ChatRoomFeature { guard let scenario else { return bundle.messages } let nodes = visibleNodes(in: scenario) let currentMs = Int(currentTime * 1000) - return nodes.flatMap { node in - node.scripts.compactMap { script -> ChatMessage? in - // 오디오가 해당 대사 시작 시점에 도달한 것만 노출 → 목소리 나올 때 대화창 한 줄씩. - // (재진입/완청 후에도 동일하게 현재 재생 위치 기준으로 싱크) - guard script.startTimeMs <= currentMs else { return nil } - return ChatMessage( - messageId: Self.scriptUUID(scriptId: script.scriptId), - speaker: speaker(for: script, in: scenario), - text: script.text, - startTimeMs: script.startTimeMs - ) + return nodes.flatMap { node -> [ChatMessage] in + let scripts = node.scripts + // 대사별 startTimeMs 가 구분되면 그 값을, 모두 같거나(0) 평평하면 노드 오디오 구간에 균등 분배. + let distinctStarts = Set(scripts.map(\.startTimeMs)).count + let nodeStartMs = scripts.map(\.startTimeMs).min() ?? 0 + let nodeDurationMs = node.audioDuration * 1000 + let count = scripts.count + + func revealStart(_ index: Int) -> Int { + if distinctStarts > 1 { return scripts[index].startTimeMs } + if count > 1 { return nodeStartMs + nodeDurationMs * index / count } + return nodeStartMs + } + + return scripts.enumerated().flatMap { index, script -> [ChatMessage] in + let scriptStart = revealStart(index) + guard currentMs >= scriptStart else { return [] } + let scriptEnd = index + 1 < count ? revealStart(index + 1) : nodeStartMs + nodeDurationMs + let windowMs = max(1, scriptEnd - scriptStart) + let messageSpeaker = speaker(for: script, in: scenario) + + // 인트로/나레이터(center)는 한 말풍선으로 전체 노출. + if messageSpeaker.side == .center { + return [ChatMessage( + messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: 0), + speaker: messageSpeaker, + text: script.text, + startTimeMs: scriptStart + )] + } + + // 발언자(좌/우): 문장마다 개별 말풍선. 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. + let sentences = Self.splitSentences(script.text) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard sentences.count > 1 else { + return [ChatMessage( + messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: 0), + speaker: messageSpeaker, + text: script.text, + startTimeMs: scriptStart + )] + } + let totalChars = max(1, sentences.reduce(0) { $0 + $1.count }) + var charsBefore = 0 + var result: [ChatMessage] = [] + for (sentenceIndex, sentence) in sentences.enumerated() { + let sentenceStart = scriptStart + windowMs * charsBefore / totalChars + charsBefore += sentence.count + guard currentMs >= sentenceStart else { break } + result.append(ChatMessage( + messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: sentenceIndex), + speaker: messageSpeaker, + text: sentence, + startTimeMs: sentenceStart + )) + } + return result } } } + /// 문장 끝(. ? ! 줄바꿈) 기준으로 분할 (구분자 포함 유지). + private static func splitSentences(_ text: String) -> [String] { + var result: [String] = [] + var current = "" + for character in text { + current.append(character) + if character == "." || character == "?" || character == "!" || character == "\n" { + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + current = "" + } + } + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + return result.isEmpty ? [text] : result + } + /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). - private static func scriptUUID(scriptId: Int) -> UUID { - let hex = String(format: "%012X", scriptId) + private static func scriptUUID(scriptId: Int, sentenceIndex: Int = 0) -> UUID { + let hex = String(format: "%08X%04X", scriptId & 0xFFFF_FFFF, sentenceIndex & 0xFFFF) return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() } - /// 현재 재생 시점에 해당하는 메시지 id. `currentTime` 이상의 startTimeMs 를 - /// 가지지 않은 마지막 메시지를 활성으로 본다. + /// 현재 재생 중(가장 최근 노출된) 메시지 id. 문장 단위 노출이라 마지막 노출 버블이 활성. 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 + messages.last?.id } public var audioUrl: String? { diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index d8828992..4726aa63 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -109,17 +109,19 @@ extension ChatRoomView { VStack(alignment: .leading, spacing: 20) { ForEach(groupedMessages, id: \.id) { group in messageGroup(group) - .id(group.messages.last?.id ?? group.id) + .transition(.opacity) } } .padding(.horizontal, 16) .padding(.vertical, 20) + // 대사가 한 줄씩 추가될 때 슬라이드/페이드인. + .animation(.easeInOut(duration: 0.25), value: store.messages) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: store.activeMessageId) { _, activeMessageId in guard let target = scrollTargetId(for: activeMessageId) else { return } withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo(target, anchor: .center) + proxy.scrollTo(target, anchor: .bottom) } } } @@ -169,6 +171,7 @@ extension ChatRoomView { VStack(spacing: 6) { ForEach(group.messages) { message in narratorBubble(text: message.text) + .id(message.id) } } .frame(maxWidth: .infinity) @@ -201,7 +204,14 @@ extension ChatRoomView { VStack(alignment: .leading, spacing: 6) { ForEach(messages) { message in - bubble(text: message.text, side: speaker.side) + let isActive = store.isPlaying && message.id == store.activeMessageId + HStack(alignment: .center, spacing: 6) { + if speaker.side == .right, isActive { waveformIcon() } + bubble(text: message.text, side: speaker.side, isActive: isActive) + .id(message.id) + if speaker.side == .left, isActive { waveformIcon() } + } + .transition(.opacity) } } } @@ -211,25 +221,35 @@ extension ChatRoomView { @ViewBuilder private func bubble( text: String, - side: ChatSpeakerSide + side: ChatSpeakerSide, + isActive: Bool = false ) -> some View { + // 재생 중인 말풍선은 흰 배경으로 강조 (테두리는 동일 유지). + let background: Color = isActive ? .beige50 : (side == .left ? .beige300 : .beige400) + let border: Color = side == .left ? .beige600 : .beige700 Text(text) .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral500) + .foregroundStyle(isActive ? .neutral800 : .neutral500) .lineSpacing(13 * 0.4) .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: Metric.bubbleMaxWidth, alignment: .leading) - .background( - side == .left ? Color.beige50 : Color.beige400, - in: RoundedRectangle(cornerRadius: 2) - ) + .background(background, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(side == .left ? Color.beige600 : Color.beige700, lineWidth: 1) + .stroke(border, lineWidth: 1) ) } + /// 재생 중 말풍선 옆 음성 파형 아이콘. + @ViewBuilder + private func waveformIcon() -> some View { + Image(systemName: "waveform") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.primary500) + .frame(width: 24, height: 24) + } + @ViewBuilder private func narratorBubble(text: String) -> some View { Text(text) From 7a1264a7a6dd8eba136619eb1b091ec4c8b1abeb Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:18:26 +0900 Subject: [PATCH 03/22] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=9E=A5=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20=EC=9C=A0=ED=8B=B8=20Utill(String+Sentence?= =?UTF-8?q?)=20=EA=B3=B5=ED=86=B5=ED=99=94=20+=20AGENTS.md=20Extension=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D(Type+)=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 33 +++++++++++++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 22 ++----------- .../Sources/Extension/String+Sentence.swift | 30 +++++++++++++++++ 3 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 Projects/Shared/Utill/Sources/Extension/String+Sentence.swift diff --git a/AGENTS.md b/AGENTS.md index 4bcfbdd7..e995da55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -498,6 +498,39 @@ public struct PreVoteFeature { - 파일 위치: `Projects/Presentation/<모듈>/Sources/<도메인>/Model/.swift` - 레퍼런스: `Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift`, `Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift` +#### 🧰 공통 유틸 — `Utill` 모듈 + `Type+이름.swift` 네이밍 (필수) + +도메인/화면에 종속되지 않는 **순수 유틸** (숫자 포맷, 문자열 분할, 날짜 변환 등) 은 Feature 안에 private helper 로 두지 말고 **`Shared/Utill` 모듈에 타입 확장**으로 분리한다. 여러 모듈(Chat / Home / Auth / Hifi 등)에서 공용으로 쓴다. + +```swift +// ✅ 올바른 패턴 — Utill 모듈의 타입 확장. 파일명은 `Type+기능.swift` +// Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift +public extension Int { + var decimalFormatted: String { ... } // 1340 → "1,340" +} + +// Projects/Shared/Utill/Sources/Extension/String+Sentence.swift +public extension String { + func splitIntoSentences() -> [String] { ... } +} + +// 사용처: import Utill 후 그대로 +import Utill +let count = likeCount.decimalFormatted +let lines = script.text.splitIntoSentences() + +// ❌ 금지 — Feature/View 안에 private static helper 로 중복 선언 +private static func formattedCount(_ n: Int) -> String { ... } // ← Utill 로 이동 +private static func splitSentences(_ t: String) -> [String] { ... } // ← Utill 로 이동 +``` + +규칙: +- **Extension 파일 네이밍은 항상 `Type+기능.swift`** — 확장 대상 타입을 접두로 (`Int+DecimalFormat.swift`, `String+Sentence.swift`, `Color+Hex.swift`, `View+Modifier.swift`). 한 파일에 한 타입 확장만. +- 순수 유틸 (Foundation 만 의존, UI/도메인 무관) → `Projects/Shared/Utill/Sources/Extension/` 에 둔다 +- 쓰는 모듈은 `.Shared(implements: .Utill)` 의존성 추가 후 `import Utill` +- DesignSystem 전용 확장(컬러/폰트/모디파이어)은 DesignSystem 모듈에 두되 파일명은 동일하게 `Type+기능.swift` +- 레퍼런스: `Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift`, `String+Sentence.swift` + #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 `do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 8ad52784..0e8f9d1e 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -13,6 +13,7 @@ import DomainInterface import Entity import LogMacro import UseCase +import Utill @Reducer public struct ChatRoomFeature { @@ -88,7 +89,7 @@ public struct ChatRoomFeature { } // 발언자(좌/우): 문장마다 개별 말풍선. 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. - let sentences = Self.splitSentences(script.text) + let sentences = script.text.splitIntoSentences() .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard sentences.count > 1 else { @@ -118,25 +119,6 @@ public struct ChatRoomFeature { } } - /// 문장 끝(. ? ! 줄바꿈) 기준으로 분할 (구분자 포함 유지). - private static func splitSentences(_ text: String) -> [String] { - var result: [String] = [] - var current = "" - for character in text { - current.append(character) - if character == "." || character == "?" || character == "!" || character == "\n" { - if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - result.append(current) - } - current = "" - } - } - if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - result.append(current) - } - return result.isEmpty ? [text] : result - } - /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). private static func scriptUUID(scriptId: Int, sentenceIndex: Int = 0) -> UUID { diff --git a/Projects/Shared/Utill/Sources/Extension/String+Sentence.swift b/Projects/Shared/Utill/Sources/Extension/String+Sentence.swift new file mode 100644 index 00000000..6f908c04 --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/String+Sentence.swift @@ -0,0 +1,30 @@ +// +// String+Sentence.swift +// Utill +// +// 문자열을 문장 단위로 분할하는 공통 유틸 (채팅 대사 한 문장씩 노출 등). +// + +import Foundation + +public extension String { + /// 문장 끝(`. ? !` / 줄바꿈) 기준으로 분할한다. 구분자는 포함, 공백뿐인 조각은 제외. + /// 분할 결과가 없으면 원본 1개를 그대로 반환. + func splitIntoSentences() -> [String] { + var result: [String] = [] + var current = "" + for character in self { + current.append(character) + if character == "." || character == "?" || character == "!" || character == "\n" { + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + current = "" + } + } + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + return result.isEmpty ? [self] : result + } +} From 951df589aafe3aa45bcc86836a2059e776646bd8 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:24:49 +0900 Subject: [PATCH 04/22] =?UTF-8?q?refactor:=20ChatRoom=20=EC=88=9C=EC=88=98?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20UUID.d?= =?UTF-8?q?eterministic(Utill)=20+=20BattleScenario+Chat(Entity,=20speaker?= =?UTF-8?q?/=EB=85=B8=EB=93=9C=EC=8B=9C=EA=B0=84)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Scenario/BattleScenario+Chat.swift | 68 +++++++++++++++++++ .../ChatRoom/Reducer/ChatRoomFeature.swift | 68 ++----------------- .../Extension/UUID+Deterministic.swift | 17 +++++ 3 files changed, 92 insertions(+), 61 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift create mode 100644 Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift new file mode 100644 index 00000000..c25f8385 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift @@ -0,0 +1,68 @@ +// +// BattleScenario+Chat.swift +// Entity +// +// 채팅방에서 쓰는 시나리오 → 화면 도메인 계산 (노드 가시성/시간, 발화자 매핑). +// currentTime 같은 UI 상태에 무관한 순수 도메인 로직만 둔다. +// + +import Foundation + +public extension BattleScenario { + /// 화면에 노출할 노드들. `visibleNodeIds` 우선, 비어있으면 현재/시작 노드 폴백. + func chatVisibleNodes(visibleNodeIds: [Int], currentNodeId: Int?) -> [ScenarioNode] { + let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? startNodeId] : visibleNodeIds + return ids.compactMap { id in nodes.first { $0.nodeId == id } } + } + + /// 노드 시작 시간(초) = 노드 내 대사 startTimeMs 최소값. + func nodeStartTime(for nodeId: Int) -> TimeInterval { + guard let node = nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } + return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + } + + /// 노드 종료 시간(초) = 노드 시작 + audioDuration. + func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + return start + TimeInterval(node.audioDuration) + } + + /// 대사(script) → 화면 발화자(ChatSpeaker) 매핑. + func chatSpeaker(for script: ScenarioScript) -> ChatSpeaker { + switch script.speakerType { + case .a: + return chatSpeaker(label: "A", side: .left, fallbackName: script.speakerName) + case .b: + return chatSpeaker(label: "B", side: .right, fallbackName: script.speakerName) + case .narrator: + return ChatSpeaker(name: script.speakerName, side: .center) + case .philosopher, .unknown: + if let philosopher = 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 chatSpeaker( + label: String, + side: ChatSpeakerSide, + fallbackName: String + ) -> ChatSpeaker { + guard let philosopher = 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 + ) + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 0e8f9d1e..a5cf1125 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -76,12 +76,12 @@ public struct ChatRoomFeature { guard currentMs >= scriptStart else { return [] } let scriptEnd = index + 1 < count ? revealStart(index + 1) : nodeStartMs + nodeDurationMs let windowMs = max(1, scriptEnd - scriptStart) - let messageSpeaker = speaker(for: script, in: scenario) + let messageSpeaker = scenario.chatSpeaker(for: script) // 인트로/나레이터(center)는 한 말풍선으로 전체 노출. if messageSpeaker.side == .center { return [ChatMessage( - messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: 0), + messageId: UUID.deterministic(script.scriptId, 0), speaker: messageSpeaker, text: script.text, startTimeMs: scriptStart @@ -94,7 +94,7 @@ public struct ChatRoomFeature { .filter { !$0.isEmpty } guard sentences.count > 1 else { return [ChatMessage( - messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: 0), + messageId: UUID.deterministic(script.scriptId, 0), speaker: messageSpeaker, text: script.text, startTimeMs: scriptStart @@ -108,7 +108,7 @@ public struct ChatRoomFeature { charsBefore += sentence.count guard currentMs >= sentenceStart else { break } result.append(ChatMessage( - messageId: Self.scriptUUID(scriptId: script.scriptId, sentenceIndex: sentenceIndex), + messageId: UUID.deterministic(script.scriptId, sentenceIndex), speaker: messageSpeaker, text: sentence, startTimeMs: sentenceStart @@ -119,13 +119,6 @@ public struct ChatRoomFeature { } } - /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 - /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). - private static func scriptUUID(scriptId: Int, sentenceIndex: Int = 0) -> UUID { - let hex = String(format: "%08X%04X", scriptId & 0xFFFF_FFFF, sentenceIndex & 0xFFFF) - return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() - } - /// 현재 재생 중(가장 최근 노출된) 메시지 id. 문장 단위 노출이라 마지막 노출 버블이 활성. public var activeMessageId: UUID? { messages.last?.id @@ -139,48 +132,6 @@ public struct ChatRoomFeature { public var canScrub: Bool { hasFinishedListening } - 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? { guard let scenario else { return nil } let target = currentNodeId ?? scenario.startNodeId @@ -216,20 +167,15 @@ public struct ChatRoomFeature { } public func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] { - let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? scenario.startNodeId] : visibleNodeIds - return ids.compactMap { id in - scenario.nodes.first { $0.nodeId == id } - } + scenario.chatVisibleNodes(visibleNodeIds: visibleNodeIds, currentNodeId: currentNodeId) } public func nodeStartTime(for nodeId: Int) -> TimeInterval { - guard let node = scenario?.nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } - return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + scenario?.nodeStartTime(for: nodeId) ?? 0 } public func nodeEndTime(for node: ScenarioNode) -> TimeInterval { - let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 - return start + TimeInterval(node.audioDuration) + scenario?.nodeEndTime(for: node) ?? 0 } } diff --git a/Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift b/Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift new file mode 100644 index 00000000..cc70dbfe --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift @@ -0,0 +1,17 @@ +// +// UUID+Deterministic.swift +// Utill +// +// 정수로부터 항상 동일한 UUID 를 생성하는 공통 유틸 (ForEach 안정 id 등). +// + +import Foundation + +public extension UUID { + /// 두 정수로부터 결정적(deterministic) UUID 생성. 같은 입력 → 항상 같은 UUID. + /// 마지막 12 hex 를 `high`(8) + `low`(4) 로 채운다. + static func deterministic(_ high: Int, _ low: Int = 0) -> UUID { + let hex = String(format: "%08X%04X", high & 0xFFFF_FFFF, low & 0xFFFF) + return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() + } +} From 7f4898b686eb518f54157ffd7be22fc9079f3751 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:28:18 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor:=20Extension=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20Type+.swift=20=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC(Int+/String+/UUID+/BattleScenario+)=20+=20AGENTS.md?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EC=A0=95=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 14 ++++++------ ...nario+Chat.swift => BattleScenario+.swift} | 0 Projects/Shared/Utill/Sources/Base.swift | 22 ------------------- .../{Int+DecimalFormat.swift => Int+.swift} | 0 .../{String+Sentence.swift => String+.swift} | 0 .../{UUID+Deterministic.swift => UUID+.swift} | 0 6 files changed, 7 insertions(+), 29 deletions(-) rename Projects/Domain/Entity/Sources/Home/Scenario/{BattleScenario+Chat.swift => BattleScenario+.swift} (100%) delete mode 100644 Projects/Shared/Utill/Sources/Base.swift rename Projects/Shared/Utill/Sources/Extension/{Int+DecimalFormat.swift => Int+.swift} (100%) rename Projects/Shared/Utill/Sources/Extension/{String+Sentence.swift => String+.swift} (100%) rename Projects/Shared/Utill/Sources/Extension/{UUID+Deterministic.swift => UUID+.swift} (100%) diff --git a/AGENTS.md b/AGENTS.md index e995da55..4e6224aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -498,18 +498,18 @@ public struct PreVoteFeature { - 파일 위치: `Projects/Presentation/<모듈>/Sources/<도메인>/Model/.swift` - 레퍼런스: `Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift`, `Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift` -#### 🧰 공통 유틸 — `Utill` 모듈 + `Type+이름.swift` 네이밍 (필수) +#### 🧰 공통 유틸 — `Utill` 모듈 + `Type+.swift` 네이밍 (필수) 도메인/화면에 종속되지 않는 **순수 유틸** (숫자 포맷, 문자열 분할, 날짜 변환 등) 은 Feature 안에 private helper 로 두지 말고 **`Shared/Utill` 모듈에 타입 확장**으로 분리한다. 여러 모듈(Chat / Home / Auth / Hifi 등)에서 공용으로 쓴다. ```swift -// ✅ 올바른 패턴 — Utill 모듈의 타입 확장. 파일명은 `Type+기능.swift` -// Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift +// ✅ 올바른 패턴 — Utill 모듈의 타입 확장. 파일명은 `Type+.swift` (기능 접미사 없음, 한 타입당 한 파일) +// Projects/Shared/Utill/Sources/Extension/Int+.swift public extension Int { var decimalFormatted: String { ... } // 1340 → "1,340" } -// Projects/Shared/Utill/Sources/Extension/String+Sentence.swift +// Projects/Shared/Utill/Sources/Extension/String+.swift public extension String { func splitIntoSentences() -> [String] { ... } } @@ -525,11 +525,11 @@ private static func splitSentences(_ t: String) -> [String] { ... } // ← Utill ``` 규칙: -- **Extension 파일 네이밍은 항상 `Type+기능.swift`** — 확장 대상 타입을 접두로 (`Int+DecimalFormat.swift`, `String+Sentence.swift`, `Color+Hex.swift`, `View+Modifier.swift`). 한 파일에 한 타입 확장만. +- **Extension 파일 네이밍은 항상 `Type+.swift`** — 확장 대상 타입 + `+` 만 (기능 접미사 없음). 한 타입 확장은 한 파일에 모은다 (`Int+.swift`, `String+.swift`, `UUID+.swift`, `Color+.swift`, `View+.swift`). 기존 `ShapeStyle+.swift`, `UIColor+.swift` 와 동일 규칙. - 순수 유틸 (Foundation 만 의존, UI/도메인 무관) → `Projects/Shared/Utill/Sources/Extension/` 에 둔다 - 쓰는 모듈은 `.Shared(implements: .Utill)` 의존성 추가 후 `import Utill` -- DesignSystem 전용 확장(컬러/폰트/모디파이어)은 DesignSystem 모듈에 두되 파일명은 동일하게 `Type+기능.swift` -- 레퍼런스: `Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift`, `String+Sentence.swift` +- DesignSystem 전용 확장(컬러/폰트/모디파이어)은 DesignSystem 모듈에 두되 파일명은 동일하게 `Type+.swift` +- 레퍼런스: `Projects/Shared/Utill/Sources/Extension/Int+.swift`, `String+.swift`, `UUID+.swift` #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+Chat.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift diff --git a/Projects/Shared/Utill/Sources/Base.swift b/Projects/Shared/Utill/Sources/Base.swift deleted file mode 100644 index 6297cc4c..00000000 --- a/Projects/Shared/Utill/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 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/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift b/Projects/Shared/Utill/Sources/Extension/Int+.swift similarity index 100% rename from Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift rename to Projects/Shared/Utill/Sources/Extension/Int+.swift diff --git a/Projects/Shared/Utill/Sources/Extension/String+Sentence.swift b/Projects/Shared/Utill/Sources/Extension/String+.swift similarity index 100% rename from Projects/Shared/Utill/Sources/Extension/String+Sentence.swift rename to Projects/Shared/Utill/Sources/Extension/String+.swift diff --git a/Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift b/Projects/Shared/Utill/Sources/Extension/UUID+.swift similarity index 100% rename from Projects/Shared/Utill/Sources/Extension/UUID+Deterministic.swift rename to Projects/Shared/Utill/Sources/Extension/UUID+.swift From ac61a337501fdb16351dce575e7fa67fb9f341c5 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:29:16 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=E2=80=94=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=AC=EB=B6=80=EB=A1=9C=20MainTab/Auth=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=ED=99=9C=EC=84=B1=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Reducer/AppReducer.swift | 2 +- .../Splash/Sources/Reducer/SplashFeature.swift | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index c6572b46..a8fc4724 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -236,7 +236,7 @@ public struct AppReducer: Sendable { private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { case .splash(.view(.onAppear)): - return .send(.view(.presentAuth)) + return .none case .splash(.delegate(.presentAuth)): return .run { send in diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 6ac0463d..0560f3ac 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -100,11 +100,11 @@ extension SplashFeature { let hasStoredCredential = hasStoredCredential return .run { send in try await clock.sleep(for: .seconds(1.2)) -// if hasStoredCredential { -// await send(.delegate(.presentMainTab)) -// } else { -// await send(.delegate(.presentAuth)) -// } + if hasStoredCredential { + await send(.delegate(.presentMainTab)) + } else { + await send(.delegate(.presentAuth)) + } } } From c1d0cb5885849500eb180220c1f316ed633e71ca Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 23:39:52 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20Battle=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=94=A9(BattleCoordinator/BattleFe?= =?UTF-8?q?ature/View)=20+=20MainTab=20=EB=B9=A0=EB=A5=B8=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=ED=83=AD=20=EC=97=B0=EA=B2=B0=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TargetDependency+Module/Modules.swift | 6 +- Projects/Presentation/Battle/Project.swift | 19 +++ .../Reducer/BattleCoordinator.swift | 108 ++++++++++++++++++ .../View/BattleCoordinatorView.swift | 31 +++++ .../Sources/Main/Reducer/BattleFeature.swift | 78 +++++++++++++ .../Battle/Sources/Main/View/BattleView.swift | 49 ++++++++ .../Battle/Tests/Sources/BattleTests.swift | 27 +++++ Projects/Presentation/MainTab/Project.swift | 3 +- .../Sources/Reducer/MainTabCoordinator.swift | 7 +- .../MainTab/Sources/View/MainTabView.swift | 3 +- 10 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 Projects/Presentation/Battle/Project.swift create mode 100644 Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift create mode 100644 Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift create mode 100644 Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift create mode 100644 Projects/Presentation/Battle/Sources/Main/View/BattleView.swift create mode 100644 Projects/Presentation/Battle/Tests/Sources/BattleTests.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 21903a25..22c142ba 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -25,12 +25,12 @@ public extension ModulePath { case MainTab case Home case Chat - + case Hifi + case Web + case Battle public static let name: String = "Presentation" - case Hifi - case Web } } diff --git a/Projects/Presentation/Battle/Project.swift b/Projects/Presentation/Battle/Project.swift new file mode 100644 index 00000000..8f1b0f0c --- /dev/null +++ b/Projects/Presentation/Battle/Project.swift @@ -0,0 +1,19 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Battle", + bundleId: .appBundleID(name: ".Battle"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Presentation(implements: .Chat), + .Shared(implements: .Shared), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift new file mode 100644 index 00000000..cda213d6 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift @@ -0,0 +1,108 @@ +// +// BattleCoordinator.swift +// Battle +// +// 빠른 배틀 탭 코디네이터. 루트는 BattleFeature, 배틀 진입 시 ChatCoordinator 로 push. +// + +import Foundation + +import Chat +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "BattleScreen", navigation: true) +public struct BattleCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.battle(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + 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 + } + } +} + +extension BattleCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + case let .routeAction(_, action: .battle(.delegate(.openBattle(battleId)))): + state.routes.push(.chat(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chat(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .chat(.delegate(.popToRoot))): + return .send(.view(.backToRootAction)) + + default: + return .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 + } + } +} + +// swiftformat:disable extensionAccessControl +extension BattleCoordinator { + @Reducer + public enum BattleScreen { + case battle(BattleFeature) + case chat(ChatCoordinator) + } +} + +// swiftformat:enable extensionAccessControl + +extension BattleCoordinator.BattleScreen.State: Equatable {} diff --git a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift new file mode 100644 index 00000000..5b5ffcf4 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift @@ -0,0 +1,31 @@ +// +// BattleCoordinatorView.swift +// Battle +// + +import Foundation + +import SwiftUI + +import Chat +import ComposableArchitecture +import TCAFlow + +public struct BattleCoordinatorView: 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 .battle(battleStore): + BattleView(store: battleStore) + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) + } + } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift new file mode 100644 index 00000000..29198fe3 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -0,0 +1,78 @@ +// +// BattleFeature.swift +// Battle +// +// 빠른 배틀 탭 루트 기능. 오늘의 배틀 / 추천 배틀 진입. +// GET /api/v1/battles (예정) +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro + +@Reducer +public struct BattleFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + + public init() {} + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case battleTapped(battleId: Int) + } + + public enum AsyncAction: Equatable { + case fetchRequested + } + + public enum InnerAction: Equatable {} + + public enum DelegateAction: Equatable { + case openBattle(battleId: Int) + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async, .inner: + return .none + + case .delegate: + return .none + } + } + } +} + +extension BattleFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchRequested)) + + case let .battleTapped(battleId): + return .send(.delegate(.openBattle(battleId: battleId))) + } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift new file mode 100644 index 00000000..bd13892b --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -0,0 +1,49 @@ +// +// BattleView.swift +// Battle +// +// 빠른 배틀 탭 루트 UI. (스캐폴딩 — #8 오늘의 배틀 구현 시 콘텐츠 채움) +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: BattleFeature.self) +public struct BattleView: View { + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + content() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .onAppear { send(.onAppear) } + } +} + +private extension BattleView { + @ViewBuilder + func content() -> some View { + VStack(spacing: 8) { + Image(asset: .noDataLogo) + .resizable() + .scaledToFit() + .frame(width: 135, height: 90) + + Text("빠른 배틀 준비 중입니다") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.beige800) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift b/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift new file mode 100644 index 00000000..4a2f4c62 --- /dev/null +++ b/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift @@ -0,0 +1,27 @@ +// +// BattleTests.swift +// Presentation.BattleTests +// +// Created by Roy on 2026-06-05. +// + +import Testing +@testable import Battle + +struct BattleTests { + + @Test + func battleExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func battleLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index 5efc2acf..4010a39d 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -15,7 +15,8 @@ let project = Project.makeAppModule( .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), .Presentation(implements: .Home), - .Presentation(implements: .Hifi) + .Presentation(implements: .Hifi), + .Presentation(implements: .Battle) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index 97a3ee5e..e7684bc6 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -10,6 +10,7 @@ import Foundation import ComposableArchitecture import TCAFlow +import Battle import DesignSystem import Hifi import Home @@ -52,7 +53,7 @@ public struct MainTabCoordinator { public var selectedTab: Int public var homeState: HomeCoordinator.State public var exploreState: HifiCoordinator.State - public var quickBattleState: HomeCoordinator.State + public var quickBattleState: BattleCoordinator.State public var myPageState: HomeCoordinator.State public init(selectedTab: Int = Tab.home.rawValue) { @@ -70,7 +71,7 @@ public struct MainTabCoordinator { case tabReselected(Int) case home(HomeCoordinator.Action) case explore(HifiCoordinator.Action) - case quickBattle(HomeCoordinator.Action) + case quickBattle(BattleCoordinator.Action) case myPage(HomeCoordinator.Action) } @@ -82,7 +83,7 @@ public struct MainTabCoordinator { HifiCoordinator() } Scope(state: \.quickBattleState, action: \.quickBattle) { - HomeCoordinator() + BattleCoordinator() } Scope(state: \.myPageState, action: \.myPage) { HomeCoordinator() diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index 6cfa1168..69b6b754 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -8,6 +8,7 @@ import SwiftUI import UIKit +import Battle import DesignSystem import Hifi import Home @@ -121,7 +122,7 @@ extension MainTabView { ) case .quickBattle: - HomeCoordinatorView( + BattleCoordinatorView( store: store.scope(state: \.quickBattleState, action: \.quickBattle) ) From 328d98316f76ad7d67761440e1160322c75cf202 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:31:12 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20DesignSystem=20=E2=80=94=20vs(ver?= =?UTF-8?q?sus=5Fhome)=20=EC=97=90=EC=85=8B=20+=20UIColor.gray200=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B6=94=EA=B0=80=20/=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20svg=20=EA=B5=90=EC=B2=B4=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onboarding1.imageset/Contents.json | 2 +- ...354\230\250\353\263\264\353\224\251 1.svg" | 61 ++++++++ .../\354\235\264\353\257\270\354\247\200.png" | Bin 22336 -> 0 bytes .../onboarding2.imageset/Contents.json | 2 +- ...354\230\250\353\263\264\353\224\251 2.svg" | 139 +++++++++++++++++ .../\354\235\264\353\257\270\354\247\200.svg" | 145 ------------------ .../onboarding3.imageset/Contents.json | 2 +- ...354\230\250\353\263\264\353\224\251 3.svg" | 40 +++++ ...354\235\264\353\257\270\354\247\200 1.png" | Bin 10573 -> 0 bytes .../onboarding4.imageset/Contents.json | 2 +- ...354\230\250\353\263\264\353\224\251 4.svg" | 45 ++++++ .../\354\235\264\353\257\270\354\247\200.png" | Bin 14538 -> 0 bytes .../vs.imageset/Contents.json | 12 ++ .../vs.imageset/versus_home.svg | 13 ++ .../Sources/Extension/Color/UIColor+.swift | 5 +- .../Sources/Image/ImageAsset.swift | 1 + 16 files changed, 318 insertions(+), 151 deletions(-) create mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" delete mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" create mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" delete mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" create mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" delete mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" create mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" delete mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json index 49558787..17dda746 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.png", + "filename" : "온보딩 1.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" new file mode 100644 index 00000000..32f7c27c --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" deleted file mode 100644 index 4b99e808281f9633947d451138674df57af4c35b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22336 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 DZuaC$ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json index a1a7abc4..acd27f1a 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.svg", + "filename" : "온보딩 2.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" new file mode 100644 index 00000000..8c433b66 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" deleted file mode 100644 index baa61afc..00000000 --- "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json index 31857c84..b3fc69ea 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지 1.png", + "filename" : "온보딩 3.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" new file mode 100644 index 00000000..e8dc7f97 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" deleted file mode 100644 index 4d1a99263d771dbebbb05a563a6769bb674d1cb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10573 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 KDq8_I4g3!`9Bb_W diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json index 49558787..e5d61ce3 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.png", + "filename" : "온보딩 4.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" new file mode 100644 index 00000000..d71c48eb --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" deleted file mode 100644 index fa6e43acce5c2367cf853e53b40be2e3ec580313..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14538 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 + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index d9337f60..4842c888 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -19,12 +19,13 @@ public extension UIColor { scanner.scanHexInt64(&rgb) let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double((rgb >> 0) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double((rgb >> 0) & 0xFF) / 255.0 self.init(red: r, green: g, blue: b, alpha: alpha) } static var neutral900: UIColor { .init(hex: "131212") } + static var gray200: UIColor { .init(hex: "B0AFAE") } static var bgDefault: UIColor { .init(hex: "FAFAF9") } static var borderDefault: UIColor { .init(hex: "EFEAE0") } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 715e900f..6869683c 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -25,6 +25,7 @@ public enum ImageAsset: String { case onboarding2 case onboarding3 case onboarding4 + case vs // MARK: - GNB 탭 아이콘 (비선택 / 선택) From c2adde459b2044a5c5774d72bf8b0e13db3fc200 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:31:12 +0900 Subject: [PATCH 09/22] =?UTF-8?q?design:=20=ED=99=88=20=E2=80=94=20Best=20?= =?UTF-8?q?=EB=B0=B0=ED=8B=80=20=EC=B9=B4=EB=93=9C=20=EC=9E=AC=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8(=EA=B5=AC=EB=B6=84=EC=84=A0/VS=20=EC=B9=A9/?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=EB=A9=94=ED=83=80)=20+=20Hero=C2=B7NewBat?= =?UTF-8?q?tle=20VS=20=EC=9D=B4=EB=AF=B8=EC=A7=80=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Components/BestBattleCardView.swift | 18 ++++++++++-------- .../View/Components/HeroCarouselView.swift | 13 +++++-------- .../View/Components/NewBattleCardView.swift | 10 ++++------ .../Home/Sources/Main/View/HomeView.swift | 9 +++++++-- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift index 6c7edfc9..bd0034e1 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -18,34 +18,36 @@ struct BestBattleCardView: View { HStack(alignment: .top, spacing: 16) { Text("\(battle.rank)") .pretendardFont(family: .Bold, size: 28) - .foregroundStyle(.primary500) + .foregroundStyle(battle.rank == 1 ? .primary500 : .neutral300) .frame(width: 28, alignment: .leading) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(battle.pair) .pretendardFont(family: .SemiBold, size: 11) .foregroundStyle(.primary500) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + Text(battle.title) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.neutral900) .lineLimit(2) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 8) { ForEach(battle.tags) { tag in Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral300) } + Spacer(minLength: 8) MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") } } - Spacer(minLength: 0) } .padding(.vertical, 16) - .padding(.horizontal, 12) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) - ) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift index 2dc7e939..92cb0de9 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -124,14 +124,10 @@ struct HeroCardView: View { Text(hero.optionA) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.beige100) - ZStack { - Circle() - .stroke(.secondary50.opacity(0.2), lineWidth: 2) - .frame(width: 32, height: 32) - Text("VS") - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.secondary50) - } + Image(asset: .vs) + .resizable() + .scaledToFit() + .frame(width: 18, height: 32) Text(hero.optionB) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.beige100) @@ -150,6 +146,7 @@ struct HeroCardView: View { Text(hero.title) .pretendardFont(family: .SemiBold, size: 16) .foregroundStyle(.beige100) + .padding(.bottom, 4) Text(hero.summary) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral200) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift index 3220d345..39c19798 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -153,11 +153,9 @@ extension NewBattleCardView { @ViewBuilder private var vsBadge: some View { - Text("VS") - .pretendardFont(family: .Bold, size: 8) - .foregroundStyle(.neutral800) - .frame(width: 24, height: 24) - .background(.secondary200, in: Circle()) - .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) + Image(asset: .vs) + .resizable() + .scaledToFit() + .frame(width: 18, height: 32) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 366fa647..1a7c9ddd 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -100,11 +100,16 @@ extension HomeView { HomeSectionHeader(title: "Best 배틀") { send(.seeMoreTapped(.bestBattles)) } - VStack(spacing: 12) { - ForEach(store.bestBattles) { battle in + VStack(spacing: 0) { + ForEach(Array(store.bestBattles.enumerated()), id: \.element.id) { index, battle in BestBattleCardView(battle: battle) .contentShape(Rectangle()) .onTapGesture { send(.bestBattleTapped(battle)) } + if index < store.bestBattles.count - 1 { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } } } .padding(.horizontal, 16) From 9ccad7f913b979540c73d9f054503d341431d164 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:31:12 +0900 Subject: [PATCH 10/22] =?UTF-8?q?design:=20=ED=83=90=EC=83=89=20=E2=80=94?= =?UTF-8?q?=20picke.pen=20=EA=B8=B0=EC=A4=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=83=AD/sort=20=EC=B9=A9/=EB=B0=B0=EA=B2=BD/?= =?UTF-8?q?=EB=A7=90=EC=A4=84=EC=9E=84=20=EC=A0=95=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Components/HifiHeaderView.swift | 12 +++------ .../Hifi/Sources/View/HifiView.swift | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift index 44549c27..accdaf3c 100644 --- a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift +++ b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift @@ -12,16 +12,16 @@ import DesignSystem /// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). struct HifiHeaderView: View { let onNotificationTapped: () -> Void - + var body: some View { HStack { Image(asset: .appLogo) .resizable() .scaledToFit() .frame(width: 62, height: 39) - + Spacer() - + Button(action: onNotificationTapped) { Image(asset: .bell) .resizable() @@ -33,11 +33,5 @@ struct HifiHeaderView: View { .padding(.vertical, 8) .frame(height: 56) .background(Color.beige50) - .overlay(alignment: .bottom) { - Rectangle() - .fill(.beige600) - .frame(height: 1) - } } } - diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift index 2c68fbbf..f1b3ce87 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -45,7 +45,6 @@ public struct HifiView: View { .contentShape(Rectangle()) .simultaneousGesture(categorySwipe) } - .background(Color.beige200.ignoresSafeArea()) .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } @@ -120,11 +119,9 @@ private extension HifiView { .padding(.horizontal, 16) } .scrollIndicators(.hidden) - .background(.beige200) + .background(.white) .overlay(alignment: .bottom) { - Rectangle() - .fill(.beige600) - .frame(height: 1.5) + Rectangle().fill(.beige600).frame(height: 1.5) } } @@ -132,9 +129,10 @@ private extension HifiView { func categoryTab(_ category: ExploreCategory) -> some View { let isSelected = store.selectedCategory == category Button { send(.categoryTapped(category)) } label: { + // picke.pen `뷰전환`: 탭 폭 50 고정, 선택 시 하단 4px primary500 풀폭 밑줄. Text(category.title) .pretendardFont(family: isSelected ? .Medium : .Regular, size: 14) - .foregroundStyle(isSelected ? Color.primary500 : Color.neutral300) + .foregroundStyle(isSelected ? Color.primary500 : Color.gray300) .frame(width: 50) .padding(.vertical, 8) .overlay(alignment: .bottom) { @@ -160,7 +158,14 @@ private extension HifiView { Button { send(.sortTapped(sort)) } label: { Text(sort.title) .pretendardFont(family: isSelected ? .SemiBold : .Medium, size: 12) - .foregroundStyle(isSelected ? Color.neutral800 : Color.neutral300) + .foregroundStyle(isSelected ? Color.beige50 : Color.primary500) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? .primary500 : .bgDefault, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.primary500, lineWidth: 1) + ) } .buttonStyle(.plain) } @@ -168,7 +173,7 @@ private extension HifiView { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(.beige200) + .background(.white) } } @@ -196,16 +201,18 @@ private extension HifiView { .foregroundStyle(.neutral500) .kerning(-0.35) .lineSpacing(14 * 0.28) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) } Text(item.summary) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) .lineSpacing(13 * 0.4) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 2) } From 0c837c9ce28580daa9c658b82ae5e54171e6d1df Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:31:13 +0900 Subject: [PATCH 11/22] =?UTF-8?q?design:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=82=98=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=AC=B8=EC=9E=A5?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=C2=B7=EC=9D=B4=ED=83=A4=EB=A6=AD/=EB=A7=90?= =?UTF-8?q?=ED=92=8D=EC=84=A0=20=ED=8F=AD=20+=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EA=B0=84=EA=B2=A9=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=ED=99=94=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatRoom/Reducer/ChatRoomFeature.swift | 13 ++----------- .../Sources/ChatRoom/View/ChatRoomView.swift | 12 +++++------- .../Chat/Sources/Vote/View/PreVoteLayout.swift | 16 ++++++++++++++-- .../Chat/Sources/Vote/View/PreVoteView.swift | 13 +++++++++---- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index a5cf1125..129c6aa0 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -78,17 +78,8 @@ public struct ChatRoomFeature { let windowMs = max(1, scriptEnd - scriptStart) let messageSpeaker = scenario.chatSpeaker(for: script) - // 인트로/나레이터(center)는 한 말풍선으로 전체 노출. - if messageSpeaker.side == .center { - return [ChatMessage( - messageId: UUID.deterministic(script.scriptId, 0), - speaker: messageSpeaker, - text: script.text, - startTimeMs: scriptStart - )] - } - - // 발언자(좌/우): 문장마다 개별 말풍선. 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. + // 나레이션/클로징(center)·발언자(좌/우) 모두 문장마다 개별 말풍선. + // 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. let sentences = script.text.splitIntoSentences() .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index 4726aa63..23153e1a 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -231,9 +231,10 @@ extension ChatRoomView { .pretendardFont(family: .Regular, size: 13) .foregroundStyle(isActive ? .neutral800 : .neutral500) .lineSpacing(13 * 0.4) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 8) .padding(.vertical, 6) - .frame(maxWidth: Metric.bubbleMaxWidth, alignment: .leading) .background(background, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) @@ -250,21 +251,18 @@ extension ChatRoomView { .frame(width: 24, height: 24) } + /// 나레이션/클로징: 박스·테두리 없이 가운데 정렬 + 살짝 기울인 이탤릭. @ViewBuilder private func narratorBubble(text: String) -> some View { Text(text) .pretendardFont(family: .Regular, size: 12) + .italic() .foregroundStyle(.neutral400) .lineSpacing(12 * 0.4) .multilineTextAlignment(.center) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, 4) .frame(maxWidth: 280) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(.beige600, lineWidth: 1) - ) } private struct SpeakerGroup: Equatable, Identifiable { diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift index bf57726c..0b7f45e9 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift @@ -9,11 +9,10 @@ import CoreGraphics enum PreVoteLayout { static let contentOverlapTopOffset: CGFloat = 280 - static let contentGradientSpacerHeight: CGFloat = 100 static let backgroundImageHeight: CGFloat = 512 static let imageToContentGradientHeight: CGFloat = 260 static let rootContentSpacing: CGFloat = 40 - static let contentToOptionSpacing: CGFloat = 32 + static let contentToOptionSpacing: CGFloat = 40 static let optionCardHeight: CGFloat = 106 static let contentHorizontalPadding: CGFloat = 16 static let contentTopPadding: CGFloat = 80 @@ -22,4 +21,17 @@ enum PreVoteLayout { static let ctaHorizontalPadding: CGFloat = 16 static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing static let snapshotWidth: CGFloat = 360 + + /// 제목·요약 길이에 따라 그라데이션 여백을 동적으로 결정(3단계). + /// 텍스트가 짧을수록 여백을 키워 콘텐츠를 아래로, 길수록 줄여 위로 끌어올린다. + static func contentGradientSpacerHeight( + titleLength: Int, + summaryLength: Int + ) -> CGFloat { + switch titleLength + summaryLength { + case ...60: 76 + case 61 ... 90: 60 + default: 52 + } + } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 8d29d126..de63ddac 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -76,11 +76,15 @@ public struct PreVoteView: View { .frame(height: PreVoteLayout.contentOverlapTopOffset) Spacer() - .frame(height: PreVoteLayout.contentGradientSpacerHeight) + .frame(height: PreVoteLayout.contentGradientSpacerHeight( + titleLength: battle.titleLine1.count + battle.titleLine2.count, + summaryLength: battle.summary.count + )) contentArea(battle) } .frame(width: proxy.size.width) + .frame(minHeight: proxy.size.height, alignment: .top) } .scrollBounceBehavior(.basedOnSize) .scrollDisabled(true) @@ -91,7 +95,6 @@ public struct PreVoteView: View { PreVoteSkeletonView() } } - } // MARK: - Background @@ -174,14 +177,16 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private func contentArea(_ battle: PreVoteBattle) -> some View { - VStack(spacing: PreVoteLayout.contentToOptionSpacing) { + VStack(spacing: 0) { contentSection(battle) + // 유연 간격: 콘텐츠는 위(상단 spacer)에 고정, 옵션은 아래로 당겨 CTA 위 40 유지. + Spacer(minLength: PreVoteLayout.contentToOptionSpacing) optionSection(battle) } .padding(.horizontal, PreVoteLayout.contentHorizontalPadding) .padding(.top, PreVoteLayout.contentTopPadding) .padding(.bottom, PreVoteLayout.contentBottomSpacing) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background( LinearGradient( stops: [ From 80ed0ef5dd51a751f88e4d98389d779bc4acaadb Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:31:13 +0900 Subject: [PATCH 12/22] =?UTF-8?q?design:=20=EB=A9=94=EC=9D=B8=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EB=AF=B8=EC=84=A0=ED=83=9D=20=ED=83=AD=20gray200?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9(=EC=95=84=EC=9D=B4=EC=BD=98=20template=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/MainTab/Sources/View/MainTabView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index 69b6b754..0a3951c0 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -45,7 +45,7 @@ public struct MainTabView: View { extension MainTabView { private static func configureTabBarAppearance() { let selectedColor = UIColor.neutral900 - let normalColor = UIColor.neutral900.withAlphaComponent(0.4) + let normalColor = UIColor.gray200 let backgroundColor = UIColor.bgDefault let borderColor = UIColor.borderDefault.withAlphaComponent(0.4) let font = UIFont.pretendardFontFamily(family: .Medium, size: 12) @@ -90,6 +90,7 @@ extension MainTabView { Label { Text(tab.title) .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(store.selectedTab == tab.tag ? Color.neutral900 : Color.gray200) } icon: { tabIcon(for: tab) } @@ -102,7 +103,7 @@ extension MainTabView { .iconAsset(isSelected: isSelected) ?? .none Image(asset: asset) - .renderingMode(.original) + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: 24, height: 24) From 3b43d312f33ca93da98ae3586899880e1b0be484 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 01:50:17 +0900 Subject: [PATCH 13/22] =?UTF-8?q?design:=20=EB=A9=94=EC=9D=B8=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EB=AF=B8=EC=84=A0=ED=83=9D=20gray200=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EC=83=89=20+=20=EC=84=A0=ED=83=9D=20=ED=83=AD=20.tint?= =?UTF-8?q?(neutral900)=20=EC=A0=9C=EC=96=B4=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/MainTab/Sources/View/MainTabView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index 0a3951c0..ec4f9893 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -39,6 +39,8 @@ public struct MainTabView: View { ) { tabContent(for: $0) } + // 기본(미선택) 색은 라벨 foregroundStyle(gray200), 선택 탭은 tint 로 제어. + .tint(.neutral900) } } @@ -90,10 +92,11 @@ extension MainTabView { Label { Text(tab.title) .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(store.selectedTab == tab.tag ? Color.neutral900 : Color.gray200) + .foregroundStyle(.gray200) } icon: { tabIcon(for: tab) } + .foregroundStyle(.gray200) } @ViewBuilder From 47d9c6a1031b879f73dda60b130f6e1b78f66eca Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:32:09 +0900 Subject: [PATCH 14/22] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EB=B0=B0=ED=8B=80=20API(/battles/today)=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=E2=80=94=20Service/DTO/Mapper/Entity/UseCase/Repository,=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20tags=20=EC=98=B5=EC=85=94=EB=84=90?= =?UTF-8?q?=ED=99=94=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/BattleDetailDataDTO.swift | 3 +- .../Battle/DTO/TodayBattleDataDTO.swift | 15 ++++ .../Battle/Mapper/BattleDetailDataDTO+.swift | 2 +- .../Battle/Mapper/TodayBattleDataDTO+.swift | 16 ++++ .../Sources/Battle/BattleRepositoryImpl.swift | 12 +++ .../Sources/Battle/BattleService.swift | 7 +- .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 4 + .../Entity/Sources/Battle/DailyBattle.swift | 83 +++++++++++++++++++ .../Sources/Battle/TodayBattlePage.swift | 21 +++++ .../Sources/Battle/BattleUseCase.swift | 4 + .../Sources/Vote/Model/ShareContent.swift | 48 ----------- .../Chat/Sources/Vote/Model/ShareItem.swift | 25 ------ 14 files changed, 168 insertions(+), 76 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Battle/DailyBattle.swift create mode 100644 Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift delete mode 100644 Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift delete mode 100644 Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 849ddae3..e14a3e56 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -6,6 +6,7 @@ import Foundation public enum BattleAPI { + case today case detail(battleId: Int) case preVote(battleId: Int) case postVote(battleId: Int) @@ -17,6 +18,8 @@ public enum BattleAPI { public var description: String { switch self { + case .today: + return "today" case let .detail(battleId): return "\(battleId)" case let .preVote(battleId): diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift index 66e67cbf..85a25f48 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -35,7 +35,8 @@ public struct BattleOptionDTO: Decodable { public let stance: String public let representative: String public let imageUrl: String - public let tags: [BattleTagDTO] + // today 배틀 옵션 응답엔 tags 가 없으므로 옵셔널. + public let tags: [BattleTagDTO]? } public struct BattleTagDTO: Decodable { diff --git a/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift new file mode 100644 index 00000000..8a5364c8 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift @@ -0,0 +1,15 @@ +// +// TodayBattleDataDTO.swift +// Model +// +// `GET /api/v1/battles/today` 응답 DTO. 아이템은 BattleInfoDTO 재사용. +// + +import Foundation + +public struct TodayBattlePageDataDTO: Decodable { + public let items: [BattleInfoDTO] + public let totalCount: Int +} + +public typealias TodayBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift index 9dfead44..deaa9ba5 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -46,7 +46,7 @@ public extension BattleOptionDTO { stance: stance, representative: representative, imageUrl: imageUrl, - tags: tags.map { $0.toDomain() } + tags: (tags ?? []).map { $0.toDomain() } ) } } diff --git a/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift new file mode 100644 index 00000000..b1d619a9 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift @@ -0,0 +1,16 @@ +// +// TodayBattleDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension TodayBattlePageDataDTO { + func toDomain() -> TodayBattlePage { + TodayBattlePage( + items: items.map { $0.toDomain() }, + totalCount: totalCount + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index f0b08674..d4aa8687 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -24,6 +24,18 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { self.provider = provider } + public func fetchTodayBattles() async throws -> TodayBattlePage { + let dto: TodayBattlePageResponseDTO = try await provider.request(.today) + + guard let data = dto.data else { + let message = dto.error?.message ?? "오늘의 배틀 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty todayBattles payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + public func fetchBattle(battleId: Int) async throws -> BattleDetail { let dto: BattleDetailResponseDTO = try await provider.request( .detail(battleId: battleId) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 58f9c096..a77e3965 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 today case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) case postVote(battleId: Int, body: PreVoteRequest) @@ -29,6 +30,8 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { + case .today: + return BattleAPI.today.description case let .detail(battleId): return BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): @@ -54,7 +57,7 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: + case .today, .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: return .get case .preVote, .postVote, .createPerspective: return .post @@ -63,6 +66,8 @@ extension BattleService: BaseTargetType { public var parameters: [String: Any]? { switch self { + case .today: + return nil case .detail: return nil case let .preVote(_, body): diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 3627f339..db3452c3 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 fetchTodayBattles() async throws -> TodayBattlePage func fetchBattle(battleId: Int) async throws -> BattleDetail func submitPreVote( battleId: Int, diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 4e618cc6..23eced77 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -9,6 +9,10 @@ import Foundation public struct DefaultBattleRepositoryImpl: BattleInterface { public init() {} + public func fetchTodayBattles() async throws -> TodayBattlePage { + TodayBattlePage(items: [], totalCount: 0) + } + public func fetchBattle(battleId _: Int) async throws -> BattleDetail { BattleDetail( battleInfo: BattleInfo( diff --git a/Projects/Domain/Entity/Sources/Battle/DailyBattle.swift b/Projects/Domain/Entity/Sources/Battle/DailyBattle.swift new file mode 100644 index 00000000..62e2e260 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/DailyBattle.swift @@ -0,0 +1,83 @@ +// +// DailyBattle.swift +// Entity +// +// 오늘의 배틀 화면 모델 — picke.pen `오늘의 배틀`. `BattleInfo` 에서 매핑. +// + +import Foundation + +public struct DailyBattle: Equatable, Identifiable { + public var battleId: Int + public var imageURL: String? + public var tags: [String] + public var title: String + public var question: String + public var durationText: String + public var options: [Option] + + public var id: Int { battleId } + + public init( + battleId: Int, + imageURL: String?, + tags: [String], + title: String, + question: String, + durationText: String, + options: [Option] + ) { + self.battleId = battleId + self.imageURL = imageURL + self.tags = tags + self.title = title + self.question = question + self.durationText = durationText + self.options = options + } + + /// 세로 카드 한 장 (대표자 / 입장 / 인용구). + public struct Option: Equatable, Identifiable { + public let id: Int + public var representative: String + public var stance: String + public var quote: String + + public init( + id: Int, + representative: String, + stance: String, + quote: String + ) { + self.id = id + self.representative = representative + self.stance = stance + self.quote = quote + } + } + + /// 서버 `BattleInfo`(오늘의 배틀 아이템) → 화면 모델 매핑. + public static func from(_ info: BattleInfo) -> DailyBattle { + DailyBattle( + battleId: info.battleId, + imageURL: info.thumbnailUrl.isEmpty ? nil : info.thumbnailUrl, + tags: info.tags.map(\.name), + title: info.title, + question: info.summary, + durationText: durationText(info.audioDuration), + options: info.options.prefix(2).map { + // API title = 짧은 입장 라벨("선하다"), API stance = 설명 문장(긴 인용구). + Option(id: $0.optionId, representative: $0.representative, stance: $0.title, quote: $0.stance) + } + ) + } + + /// 재생시간 초 → "X분 Y초". + private static func durationText(_ seconds: Int) -> String { + let minutes = seconds / 60 + let secs = seconds % 60 + if minutes > 0, secs > 0 { return "\(minutes)분 \(secs)초" } + if minutes > 0 { return "\(minutes)분" } + return "\(secs)초" + } +} diff --git a/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift b/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift new file mode 100644 index 00000000..7f3cd930 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift @@ -0,0 +1,21 @@ +// +// TodayBattlePage.swift +// Entity +// +// `GET /api/v1/battles/today` 응답 도메인 모델. 아이템은 BattleInfo 재사용. +// + +import Foundation + +public struct TodayBattlePage: Equatable { + public let items: [BattleInfo] + public let totalCount: Int + + public init( + items: [BattleInfo], + totalCount: Int + ) { + self.items = items + self.totalCount = totalCount + } +} diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index 30476cb9..b3bb82c3 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -15,6 +15,10 @@ public struct BattleUseCaseImpl: BattleInterface { public init() {} + public func fetchTodayBattles() async throws -> TodayBattlePage { + return try await battleRepository.fetchTodayBattles() + } + public func fetchBattle(battleId: Int) async throws -> BattleDetail { return try await battleRepository.fetchBattle(battleId: battleId) } diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift deleted file mode 100644 index d63224b4..00000000 --- a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ShareContent.swift -// Chat -// -// 공유 시트에 실어 보낼 컨텐츠 묶음. 카드 스냅샷(우선) + 썸네일(fallback) + 본문 텍스트 + URL. -// - -import Foundation - -public struct ShareContent: Equatable { - public let title: String - public let summary: String - public let hashtags: [String] - public let optionLine: String? - public let url: String - public let thumbnailURL: String? - public let snapshotData: Data? - - public init( - title: String, - summary: String, - hashtags: [String], - optionLine: String?, - url: String, - thumbnailURL: String?, - snapshotData: Data? - ) { - self.title = title - self.summary = summary - self.hashtags = hashtags - self.optionLine = optionLine - self.url = url - self.thumbnailURL = thumbnailURL - self.snapshotData = snapshotData - } - - /// SNS / 메시지 공유 시 동행되는 본문 텍스트. - /// 빈 섹션은 자동으로 건너뛴다. - public var displayText: String { - var lines: [String] = [] - if !title.isEmpty { lines.append(title) } - if !summary.isEmpty { lines.append(summary) } - if let optionLine, !optionLine.isEmpty { lines.append(optionLine) } - let joinedHashtags = hashtags.joined(separator: " ") - if !joinedHashtags.isEmpty { lines.append(joinedHashtags) } - return lines.joined(separator: "\n\n") - } -} diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift deleted file mode 100644 index c2b951fb..00000000 --- a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ShareItem.swift -// Chat -// -// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. -// - -import Foundation - -public struct ShareItem: Equatable, Identifiable { - public let id: UUID - public let items: [Any] - - public init( - id: UUID = UUID(), - items: [Any] - ) { - self.id = id - self.items = items - } - - public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { - lhs.id == rhs.id - } -} From bfd6b3e6ae6ddc8ddd7a527d942aaf87d3a322de Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:32:10 +0900 Subject: [PATCH 15/22] =?UTF-8?q?feat:=20=EB=B9=A0=EB=A5=B8=EB=B0=B0?= =?UTF-8?q?=ED=8B=80(=EC=98=A4=EB=8A=98=EC=9D=98=20=EB=B0=B0=ED=8B=80)=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=84=B8=EB=A1=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A0=80/=EC=98=B5=EC=85=98=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=C2=B7=EB=B9=84=ED=99=9C=EC=84=B1/=EA=B3=B5=EC=9C=A0/ChatRoom?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85,=20picke.pen=20=EB=A7=A4=EC=B9=AD=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/Battle/Project.swift | 7 +- .../Reducer/BattleCoordinator.swift | 10 + .../View/BattleCoordinatorView.swift | 2 + .../Sources/Main/Reducer/BattleFeature.swift | 119 ++++++- .../Battle/Sources/Main/View/BattleView.swift | 315 +++++++++++++++++- .../View/Components/BattleSkeletonView.swift | 77 +++++ 6 files changed, 510 insertions(+), 20 deletions(-) create mode 100644 Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift diff --git a/Projects/Presentation/Battle/Project.swift b/Projects/Presentation/Battle/Project.swift index 8f1b0f0c..8f52e705 100644 --- a/Projects/Presentation/Battle/Project.swift +++ b/Projects/Presentation/Battle/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: "Battle", @@ -12,8 +12,9 @@ let project = Project.makeAppModule( dependencies: [ .Presentation(implements: .Chat), .Shared(implements: .Shared), + .Domain(implements: .UseCase), .SPM.composableArchitecture, .SPM.tcaFlow, ], sources: ["Sources/**"] -) \ No newline at end of file +) diff --git a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift index cda213d6..d124a445 100644 --- a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift +++ b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift @@ -64,7 +64,16 @@ extension BattleCoordinator { action: IndexedRouterActionOf ) -> Effect { switch action { + // 오늘의 배틀에서 선택 후 입장 → ChatRoom(채팅방) 직접 진입. case let .routeAction(_, action: .battle(.delegate(.openBattle(battleId)))): + state.routes.push(.chatRoom(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 채팅방 다 들으면 → ChatCoordinator(초기 PreVote) 흐름으로 연결. + case let .routeAction(_, action: .chatRoom(.delegate(.requestFinalVote(battleId)))): state.routes.push(.chat(.init(battleId: battleId))) return .none @@ -99,6 +108,7 @@ extension BattleCoordinator { @Reducer public enum BattleScreen { case battle(BattleFeature) + case chatRoom(ChatRoomFeature) case chat(ChatCoordinator) } } diff --git a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift index 5b5ffcf4..53318440 100644 --- a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift +++ b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift @@ -23,6 +23,8 @@ public struct BattleCoordinatorView: View { switch screen.case { case let .battle(battleStore): BattleView(store: battleStore) + case let .chatRoom(chatRoomStore): + ChatRoomView(store: chatRoomStore) case let .chat(chatStore): ChatCoordinatorView(store: chatStore) } diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift index 29198fe3..53c788a5 100644 --- a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -2,8 +2,9 @@ // BattleFeature.swift // Battle // -// 빠른 배틀 탭 루트 기능. 오늘의 배틀 / 추천 배틀 진입. -// GET /api/v1/battles (예정) +// 빠른 배틀 탭 루트 기능 — picke.pen `오늘의 배틀`. +// 배경 이미지 + 제목/소요시간 + VS 선택지 + "배틀 입장하기". +// GET /api/v1/battles (예정) — 현재는 목 데이터. // import Foundation @@ -11,6 +12,7 @@ import Foundation import ComposableArchitecture import Entity import LogMacro +import UseCase @Reducer public struct BattleFeature { @@ -19,11 +21,24 @@ public struct BattleFeature { @ObservableState public struct State: Equatable { public var isLoading: Bool = false + /// 오늘의 배틀 목록 — 세로 스크롤로 다음 배틀 노출. 비어있으면 "없음". + public var battles: [DailyBattle] = [] + /// 배틀별 선택한 옵션 (battleId → optionId). 미선택 시 "배틀 입장하기" 비활성. + public var selectedOptionByBattle: [Int: Int] = [:] + /// 이미 입장(투표)한 배틀 — 복귀 시 옵션 변경 비활성화. + public var votedBattleIds: Set = [] + /// 시스템 공유 시트 트리거. + public var shareItem: ShareItem? public init() {} + + public func selectedOption(for battleId: Int) -> Int? { + selectedOptionByBattle[battleId] + } } - public enum Action: ViewAction { + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -33,27 +48,48 @@ public struct BattleFeature { @CasePathable public enum View { case onAppear - case battleTapped(battleId: Int) + case backTapped + case shareTapped(battleId: Int) + case optionTapped(battleId: Int, optionId: Int) + case enterBattleTapped(battleId: Int) } public enum AsyncAction: Equatable { case fetchRequested } - public enum InnerAction: Equatable {} + public enum InnerAction: Equatable { + case todayResponse(Result) + } + + nonisolated enum CancelID: Hashable { + case fetchToday + } + + @Dependency(\.battleUseCase) private var battleUseCase public enum DelegateAction: Equatable { + /// 배틀 입장 → 채팅방 진입 case openBattle(battleId: Int) + /// 상단 백탭 → 홈 탭으로 복귀 + case backToHome } public var body: some Reducer { + BindingReducer() Reduce { state, action in switch action { + case .binding: + return .none + case let .view(viewAction): return handleViewAction(state: &state, action: viewAction) - case .async, .inner: - return .none + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) case .delegate: return .none @@ -64,15 +100,80 @@ public struct BattleFeature { extension BattleFeature { private func handleViewAction( - state _: inout State, + state: inout State, action: View ) -> Effect { switch action { case .onAppear: return .send(.async(.fetchRequested)) - case let .battleTapped(battleId): + case .backTapped: + return .send(.delegate(.backToHome)) + + case let .shareTapped(battleId): + guard let battle = state.battles.first(where: { $0.battleId == battleId }) else { return .none } + let text = [ + battle.title, + battle.question, + battle.tags.map { "#\($0)" }.joined(separator: " "), + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + var items: [Any] = [text] + if let urlString = battle.imageURL, let url = URL(string: urlString) { items.append(url) } + state.shareItem = ShareItem(items: items) + return .none + + case let .optionTapped(battleId, optionId): + // 같은 옵션 재탭 시 해제, 아니면 선택. + if state.selectedOptionByBattle[battleId] == optionId { + state.selectedOptionByBattle[battleId] = nil + } else { + state.selectedOptionByBattle[battleId] = optionId + } + return .none + + case let .enterBattleTapped(battleId): + // 선택해야만 입장 가능. 입장 시 투표 확정 → 복귀 시 옵션 변경 비활성화. + guard state.selectedOptionByBattle[battleId] != nil else { return .none } + state.votedBattleIds.insert(battleId) return .send(.delegate(.openBattle(battleId: battleId))) } } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchRequested: + state.isLoading = true + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.fetchTodayBattles() + } + .mapError(BattleError.from) + return await send(.inner(.todayResponse(result))) + } + .cancellable(id: CancelID.fetchToday, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .todayResponse(result): + state.isLoading = false + switch result { + case let .success(page): + state.battles = page.items.map(DailyBattle.from) + case let .failure(error): + state.battles = [] + Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)") + } + return .none + } + } } diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift index bd13892b..349b25aa 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -2,7 +2,9 @@ // BattleView.swift // Battle // -// 빠른 배틀 탭 루트 UI. (스캐폴딩 — #8 오늘의 배틀 구현 시 콘텐츠 채움) +// 빠른 배틀 탭 루트 UI — picke.pen `오늘의 배틀`. +// 배경 이미지 + 하단 그라데이션 + 태그/제목/질문/소요시간 + 세로 VS 선택지 + "배틀 입장하기". +// 오늘의 배틀이 여러 건이면 세로 스크롤(페이징)로 다음 배틀 노출. // import SwiftUI @@ -10,39 +12,336 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Kingfisher @ViewAction(for: BattleFeature.self) public struct BattleView: View { - public let store: StoreOf + @Bindable public var store: StoreOf + /// 현재 보이는 배틀 (세로 페이징) — 공유 시 대상 식별. + @State private var currentBattleId: Int? public init(store: StoreOf) { self.store = store } public var body: some View { - VStack(spacing: 0) { - content() + // ZStack 은 safe-area 존중(ignoresSafeArea 미적용) → appBar 가 노치 아래 + 항상 최상단 탭 가능. + // 배경/pager 는 각자 내부에서 ignoresSafeArea 로 풀블리드 유지. + ZStack { + Color.neutral900.ignoresSafeArea() + + if store.isLoading { + BattleSkeletonView() + } else if store.battles.isEmpty { + emptyState() + } else { + pager() + } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.beige200.ignoresSafeArea()) + // PreVoteView 패턴: 앱바는 overlay 로 올려 항상 최상단·탭 가능하게. + .overlay(alignment: .top) { + appBar() + .padding(.top, 10) + .contentShape(Rectangle()) + .zIndex(10) + } .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } + // 데이터 로드 후 첫 페이지를 현재 배틀로 초기화 (스크롤 전엔 scrollPosition 이 nil). + .onChange(of: store.battles.map(\.id)) { _, ids in + if currentBattleId == nil { currentBattleId = ids.first } + } + .sheet(item: $store.shareItem) { item in + ShareSheet(items: item.items) + .presentationDetents([.fraction(0.5)]) + } + } +} + +// MARK: - Pager (세로 페이징) + +private extension BattleView { + @ViewBuilder + func pager() -> some View { + GeometryReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(store.battles) { battle in + battlePage(battle, size: proxy.size) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentBattleId) + } + .ignoresSafeArea() + } + + // PreVoteView 패턴: 배경 이미지와 콘텐츠 각각에 proxy 폭을 명시해 폭을 제약한다. + @ViewBuilder + func battlePage(_ battle: DailyBattle, size: CGSize) -> some View { + ZStack(alignment: .bottom) { + backgroundImage(battle.imageURL) + .frame(width: size.width, height: size.height) + + bottomContent(battle) + .frame(width: size.width) + } + .frame(width: size.width, height: size.height) + } +} + +// MARK: - Background + +private extension BattleView { + @ViewBuilder + func backgroundImage(_ imageURL: String?) -> some View { + ZStack { + if let imageURL, let url = URL(string: imageURL) { + KFImage(url) + .placeholder { Color.neutral800 } + .resizable() + .scaledToFill() + } else { + Color.neutral800 + } + + LinearGradient( + stops: [ + .init(color: .neutral900.opacity(0), location: 0), + .init(color: .neutral900.opacity(0.85), location: 0.55), + .init(color: .neutral900, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } +} + +// MARK: - App Bar + +private extension BattleView { + @ViewBuilder + func appBar() -> some View { + HStack { + Button { send(.backTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.beige50) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + // 데이터 없을 땐 공유 숨김. + if let battleId = currentBattleId ?? store.battles.first?.battleId { + Button { send(.shareTapped(battleId: battleId)) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.beige50) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) } } +// MARK: - Bottom Content + +private extension BattleView { + @ViewBuilder + func bottomContent(_ battle: DailyBattle) -> some View { + VStack(spacing: 32) { + contentSection(battle) + vsOptions(battle) + enterButton(battle) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + + @ViewBuilder + func contentSection(_ battle: DailyBattle) -> some View { + VStack(spacing: 16) { + VStack(spacing: 12) { + VStack(spacing: 20) { + tagsRow(battle.tags) + Text(battle.title) + .pretendardFont(family: .Bold, size: 24) + .foregroundStyle(.beige50) + .kerning(-0.6) + .multilineTextAlignment(.center) + .lineSpacing(24 * 0.18) + .lineLimit(2) + .minimumScaleFactor(0.85) + .fixedSize(horizontal: false, vertical: true) + } + + if !battle.question.isEmpty { + Text(battle.question) + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + durationBadge(battle.durationText) + } + } + + @ViewBuilder + func tagsRow(_ tags: [String]) -> some View { + HStack(spacing: 9) { + ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in + Text("#\(tag)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + } + + @ViewBuilder + func durationBadge(_ text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 11, weight: .semibold)) + Text(text) + .pretendardFont(family: .SemiBold, size: 12) + } + .foregroundStyle(.gray300) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.gray500, lineWidth: 1) + ) + } +} + +// MARK: - VS Options (세로 카드 + VS 배지) + +private extension BattleView { + @ViewBuilder + func vsOptions(_ battle: DailyBattle) -> some View { + let options = battle.options + let selectedId = store.selectedOptionByBattle[battle.battleId] + // 이미 입장(투표)한 배틀이면 옵션 변경 비활성화. + let isVoted = store.votedBattleIds.contains(battle.battleId) + ZStack { + VStack(spacing: 12) { + ForEach(options) { option in + optionCard(option, isSelected: selectedId == option.id) { + send(.optionTapped(battleId: battle.battleId, optionId: option.id)) + } + } + } + .disabled(isVoted) + + if options.count > 1 { + vsBadge() + } + } + } + + /// 선택 시 picke.pen 강조(neutral900 배경 + 골드 테두리), 미선택은 gray700. + @ViewBuilder + func optionCard( + _ option: DailyBattle.Option, + isSelected: Bool, + onTap: @escaping () -> Void + ) -> some View { + Button(action: onTap) { + VStack(spacing: 0) { + Text(option.representative) + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(.secondary500) + .kerning(1.5) + .padding(.bottom, 8) + + Text(option.stance) + .pretendardFont(family: .Bold, size: 18) + .foregroundStyle(.beige50) + .kerning(-0.45) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 20) + + if !option.quote.isEmpty { + Text(option.quote) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.top, 10) + .padding(.horizontal, 20) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(isSelected ? Color.neutral900 : Color.gray700, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? .secondary500 : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func vsBadge() -> some View { + Text("VS") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .frame(width: 40, height: 40) + .background(.secondary200, in: Circle()) + .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) + } +} + +// MARK: - CTA + +private extension BattleView { + @ViewBuilder + func enterButton(_ battle: DailyBattle) -> some View { + CustomButton( + action: { send(.enterBattleTapped(battleId: battle.battleId)) }, + title: "배틀 입장하기", + config: CustomButtonConfig.primary(.large, height: 52), + isEnable: store.selectedOptionByBattle[battle.battleId] != nil + ) + } +} + +// MARK: - Empty / Skeleton + private extension BattleView { @ViewBuilder - func content() -> some View { + func emptyState() -> some View { VStack(spacing: 8) { Image(asset: .noDataLogo) .resizable() .scaledToFit() .frame(width: 135, height: 90) - Text("빠른 배틀 준비 중입니다") + Text("오늘의 배틀이 없습니다") .pretendardCustomFont(textStyle: .bodyMedium) - .foregroundStyle(.beige800) + .foregroundStyle(.beige300) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift b/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift new file mode 100644 index 00000000..7e208384 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift @@ -0,0 +1,77 @@ +// +// BattleSkeletonView.swift +// Battle +// +// 오늘의 배틀 로딩 스켈레톤 — 다크 배경에 맞춘 어두운 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct BattleSkeletonView: View { + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + block(width: 120, height: 18) + block(width: 260, height: 28) + block(width: 220, height: 18) + block(width: 90, height: 28) + } + + VStack(spacing: 12) { + block(maxWidth: true, height: 96) + block(maxWidth: true, height: 96) + } + + block(maxWidth: true, height: 52) + } + .padding(.horizontal, 16) + .padding(.bottom, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + + @ViewBuilder + private func block( + width: CGFloat? = nil, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + DarkSkeletonBlock(cornerRadius: 2) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} + +/// 다크 배경용 skeleton 블록 (어두운 base + 옅은 흰색 shimmer). +private struct DarkSkeletonBlock: View { + let cornerRadius: CGFloat + + @State private var phase: CGFloat = -1 + + private let baseColor = Color.white.opacity(0.08) + private let shimmerColor = Color.white.opacity(0.20) + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: shimmerColor.opacity(0), location: 0), + .init(color: shimmerColor, location: 0.5), + .init(color: shimmerColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} From 31c2d1e6d04e0eb4a66fc4eaedec62cd76c199e3 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:32:24 +0900 Subject: [PATCH 16/22] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A7=84=EC=98=81=20=E2=80=94=20vote-stats=20optio?= =?UTF-8?q?nId=200=20=EC=8B=9C=20=EB=B0=B0=ED=8B=80=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EA=B0=92=20=EC=9C=A0=EC=A7=80(=ED=88=AC=ED=91=9C=20=EC=A7=84?= =?UTF-8?q?=EC=98=81=EC=9C=BC=EB=A1=9C=20=EC=9E=98=EB=AA=BB=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EB=90=98=EB=8D=98=20=EB=B2=84=EA=B7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Sources/Comment/Reducer/CommentFeature.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 42169315..f99ceeab 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -599,7 +599,9 @@ extension CommentFeature { return VoteSummary( changeBadgeTitle: fallback.changeBadgeTitle, optionA: VoteOptionSummary( - optionId: a.optionId, + // vote-stats optionId 가 0(누락)이면 배틀 상세에서 채운 값(fallback)을 유지 — 그래야 + // 옵션 탭에서 댓글 등록 시 해당 진영 optionId 로 정확히 등록된다. + optionId: a.optionId > 0 ? a.optionId : fallback.optionA.optionId, label: a.label ?? fallback.optionA.label, title: a.title.isEmpty ? fallback.optionA.title : a.title, representative: fallback.optionA.representative, @@ -607,7 +609,7 @@ extension CommentFeature { percentage: a.ratio ), optionB: VoteOptionSummary( - optionId: b.optionId, + optionId: b.optionId > 0 ? b.optionId : fallback.optionB.optionId, label: b.label ?? fallback.optionB.label, title: b.title.isEmpty ? fallback.optionB.title : b.title, representative: fallback.optionB.representative, From 01f8ff18a83f79e937d775607b0cb6ec38a6dabd Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:32:25 +0900 Subject: [PATCH 17/22] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EB=B9=A0=EB=A5=B8=EB=B0=B0=ED=8B=80=20=EB=B0=B1?= =?UTF-8?q?=ED=83=AD=20=EC=8B=9C=20=EC=A7=81=EC=A0=84=20=ED=83=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B5=EA=B7=80(previousTab=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MainTab/Sources/Reducer/MainTabCoordinator.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index e7684bc6..a1f326eb 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -51,6 +51,8 @@ public struct MainTabCoordinator { @ObservableState public struct State: Equatable { public var selectedTab: Int + /// 직전 탭 — 탐색/빠른배틀 상단 백탭 시 이 탭으로 복귀. + public var previousTab: Int = Tab.home.rawValue public var homeState: HomeCoordinator.State public var exploreState: HifiCoordinator.State public var quickBattleState: BattleCoordinator.State @@ -92,6 +94,7 @@ public struct MainTabCoordinator { Reduce { state, action in switch action { case let .selectTab(tab): + if tab != state.selectedTab { state.previousTab = state.selectedTab } state.selectedTab = tab return .none @@ -106,6 +109,11 @@ public struct MainTabCoordinator { } return .none + // 빠른배틀(오늘의 배틀) 상단 백탭 → 직전 탭으로 복귀 + case .quickBattle(.router(.routeAction(_, action: .battle(.delegate(.backToHome))))): + state.selectedTab = state.previousTab + return .none + case .home, .explore, .quickBattle, .myPage: return .none } From a5f9e2cc4a16dca652a116297db2dd7b3de167e5 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:32:49 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor:=20ShareItem/ShareContent=20Enti?= =?UTF-8?q?ty=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80(=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?)=20+=20=EA=B3=B5=EC=9C=A0=20=EC=8B=9C=ED=8A=B8=2050%=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entity/Sources/Share/ShareContent.swift | 49 +++++++++++++++++++ .../Entity/Sources/Share/ShareItem.swift | 25 ++++++++++ .../Chat/Sources/Vote/View/PreVoteView.swift | 2 +- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 Projects/Domain/Entity/Sources/Share/ShareContent.swift create mode 100644 Projects/Domain/Entity/Sources/Share/ShareItem.swift diff --git a/Projects/Domain/Entity/Sources/Share/ShareContent.swift b/Projects/Domain/Entity/Sources/Share/ShareContent.swift new file mode 100644 index 00000000..920f7cab --- /dev/null +++ b/Projects/Domain/Entity/Sources/Share/ShareContent.swift @@ -0,0 +1,49 @@ +// +// ShareContent.swift +// Entity +// +// 공유 시트에 실어 보낼 컨텐츠 묶음. 카드 스냅샷(우선) + 썸네일(fallback) + 본문 텍스트 + URL. +// (PreVote / 오늘의 배틀 등 공용) +// + +import Foundation + +public struct ShareContent: Equatable { + public let title: String + public let summary: String + public let hashtags: [String] + public let optionLine: String? + public let url: String + public let thumbnailURL: String? + public let snapshotData: Data? + + public init( + title: String, + summary: String, + hashtags: [String], + optionLine: String?, + url: String, + thumbnailURL: String?, + snapshotData: Data? + ) { + self.title = title + self.summary = summary + self.hashtags = hashtags + self.optionLine = optionLine + self.url = url + self.thumbnailURL = thumbnailURL + self.snapshotData = snapshotData + } + + /// SNS / 메시지 공유 시 동행되는 본문 텍스트. + /// 빈 섹션은 자동으로 건너뛴다. + public var displayText: String { + var lines: [String] = [] + if !title.isEmpty { lines.append(title) } + if !summary.isEmpty { lines.append(summary) } + if let optionLine, !optionLine.isEmpty { lines.append(optionLine) } + let joinedHashtags = hashtags.joined(separator: " ") + if !joinedHashtags.isEmpty { lines.append(joinedHashtags) } + return lines.joined(separator: "\n\n") + } +} diff --git a/Projects/Domain/Entity/Sources/Share/ShareItem.swift b/Projects/Domain/Entity/Sources/Share/ShareItem.swift new file mode 100644 index 00000000..1f66c2da --- /dev/null +++ b/Projects/Domain/Entity/Sources/Share/ShareItem.swift @@ -0,0 +1,25 @@ +// +// ShareItem.swift +// Entity +// +// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. (PreVote / 오늘의 배틀 등 공용) +// + +import Foundation + +public struct ShareItem: Equatable, Identifiable { + public let id: UUID + public let items: [Any] + + public init( + id: UUID = UUID(), + items: [Any] + ) { + self.id = id + self.items = items + } + + public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index de63ddac..6e022776 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -52,7 +52,7 @@ public struct PreVoteView: View { .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) - .presentationDetents([.fraction(0.6)]) + .presentationDetents([.fraction(0.5)]) .toolbar(.hidden, for: .navigationBar) } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) From 3f7ec083f6096abb4b57ef5d5a99f18a75e9d4dc Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:36:43 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EC=A7=84?= =?UTF-8?q?=EC=98=81=20=ED=91=9C=EC=8B=9C=20=E2=80=94=20optionId=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A1=9C=20.a/.b=20=ED=8C=90=EB=B3=84(?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20label=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EB=B0=98=EB=8C=80=20=EC=A7=84=EC=98=81=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B2=84=EA=B7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Sources/Comment/Reducer/CommentFeature.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index f99ceeab..7463f776 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -530,8 +530,13 @@ extension CommentFeature { switch result { case let .success(page): let myPid = state.perspectiveId + // 진영(.a/.b) 은 옵션 label 문자열이 아니라 optionId 로 판별(서버 label 이 "A"/"B" 가 아닐 수 있음). + let optionAId = state.voteSummary.optionA.optionId let mapped = page.items.enumerated().map { idx, item -> CommentItem in var comment = CommentItem(item: item, order: idx) + if optionAId > 0 { + comment.option = item.option.optionId == optionAId ? .a : .b + } // 서버 isMyPerspective 가 누락/false 여도 내 perspectiveId 와 일치하면 내 글로 판정. if let myPid, comment.perspectiveId == myPid { comment.isMine = true From 7ed633b076f0b74187bdb7a103d5f80f4be7d629 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:51:40 +0900 Subject: [PATCH 20/22] =?UTF-8?q?refactor:=20=EB=B9=A0=EB=A5=B8=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=ED=99=94=EB=A9=B4=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20?= =?UTF-8?q?Presentation=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오늘의 배틀 화면 모델과 채팅방 전용 시나리오 매핑을 Presentation 레이어로 되돌려 Domain 의존 방향을 유지한다. Rejected: BattleScenario Entity 확장에 ChatSpeaker 매핑 유지 | Presentation 타입 의존이 생김 --- .../Home/Scenario/BattleScenario+.swift | 50 +------------- .../Sources/Main/Model}/DailyBattle.swift | 16 ++--- .../ChatRoom/Reducer/ChatRoomFeature.swift | 67 +++++++++++++++++-- .../Sources/Curation/View/CurationView.swift | 8 +-- .../Shared/Utill/Sources/Extension/Int+.swift | 15 +++++ 5 files changed, 86 insertions(+), 70 deletions(-) rename Projects/{Domain/Entity/Sources/Battle => Presentation/Battle/Sources/Main/Model}/DailyBattle.swift (83%) diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift index c25f8385..ab2522c5 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift @@ -1,20 +1,13 @@ // -// BattleScenario+Chat.swift +// BattleScenario+Timeline.swift // Entity // -// 채팅방에서 쓰는 시나리오 → 화면 도메인 계산 (노드 가시성/시간, 발화자 매핑). -// currentTime 같은 UI 상태에 무관한 순수 도메인 로직만 둔다. +// 시나리오 타임라인 계산. // import Foundation public extension BattleScenario { - /// 화면에 노출할 노드들. `visibleNodeIds` 우선, 비어있으면 현재/시작 노드 폴백. - func chatVisibleNodes(visibleNodeIds: [Int], currentNodeId: Int?) -> [ScenarioNode] { - let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? startNodeId] : visibleNodeIds - return ids.compactMap { id in nodes.first { $0.nodeId == id } } - } - /// 노드 시작 시간(초) = 노드 내 대사 startTimeMs 최소값. func nodeStartTime(for nodeId: Int) -> TimeInterval { guard let node = nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } @@ -26,43 +19,4 @@ public extension BattleScenario { let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 return start + TimeInterval(node.audioDuration) } - - /// 대사(script) → 화면 발화자(ChatSpeaker) 매핑. - func chatSpeaker(for script: ScenarioScript) -> ChatSpeaker { - switch script.speakerType { - case .a: - return chatSpeaker(label: "A", side: .left, fallbackName: script.speakerName) - case .b: - return chatSpeaker(label: "B", side: .right, fallbackName: script.speakerName) - case .narrator: - return ChatSpeaker(name: script.speakerName, side: .center) - case .philosopher, .unknown: - if let philosopher = 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 chatSpeaker( - label: String, - side: ChatSpeakerSide, - fallbackName: String - ) -> ChatSpeaker { - guard let philosopher = 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 - ) - } } diff --git a/Projects/Domain/Entity/Sources/Battle/DailyBattle.swift b/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift similarity index 83% rename from Projects/Domain/Entity/Sources/Battle/DailyBattle.swift rename to Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift index 62e2e260..bac61548 100644 --- a/Projects/Domain/Entity/Sources/Battle/DailyBattle.swift +++ b/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift @@ -1,12 +1,15 @@ // // DailyBattle.swift -// Entity +// Battle // // 오늘의 배틀 화면 모델 — picke.pen `오늘의 배틀`. `BattleInfo` 에서 매핑. // import Foundation +import Entity +import Utill + public struct DailyBattle: Equatable, Identifiable { public var battleId: Int public var imageURL: String? @@ -64,20 +67,11 @@ public struct DailyBattle: Equatable, Identifiable { tags: info.tags.map(\.name), title: info.title, question: info.summary, - durationText: durationText(info.audioDuration), + durationText: info.audioDuration.durationText, options: info.options.prefix(2).map { // API title = 짧은 입장 라벨("선하다"), API stance = 설명 문장(긴 인용구). Option(id: $0.optionId, representative: $0.representative, stance: $0.title, quote: $0.stance) } ) } - - /// 재생시간 초 → "X분 Y초". - private static func durationText(_ seconds: Int) -> String { - let minutes = seconds / 60 - let secs = seconds % 60 - if minutes > 0, secs > 0 { return "\(minutes)분 \(secs)초" } - if minutes > 0 { return "\(minutes)분" } - return "\(secs)초" - } } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 129c6aa0..ac3132cf 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -76,7 +76,7 @@ public struct ChatRoomFeature { guard currentMs >= scriptStart else { return [] } let scriptEnd = index + 1 < count ? revealStart(index + 1) : nodeStartMs + nodeDurationMs let windowMs = max(1, scriptEnd - scriptStart) - let messageSpeaker = scenario.chatSpeaker(for: script) + let messageSpeaker = speaker(for: script, in: scenario) // 나레이션/클로징(center)·발언자(좌/우) 모두 문장마다 개별 말풍선. // 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. @@ -157,10 +157,6 @@ public struct ChatRoomFeature { "picke.chatRoom.hasFinishedListening.\(battleId)" } - public func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] { - scenario.chatVisibleNodes(visibleNodeIds: visibleNodeIds, currentNodeId: currentNodeId) - } - public func nodeStartTime(for nodeId: Int) -> TimeInterval { scenario?.nodeStartTime(for: nodeId) ?? 0 } @@ -250,6 +246,67 @@ public struct ChatRoomFeature { } } +private extension ChatRoomFeature.State { + func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] { + let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? scenario.startNodeId] : visibleNodeIds + return ids.compactMap { id in + scenario.nodes.first { $0.nodeId == id } + } + } + + 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) + } + } + + 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 + ) + } +} + extension ChatRoomFeature { private func handleViewAction( state: inout State, diff --git a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift index 2bec5b91..bf6d641a 100644 --- a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift +++ b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Utill @ViewAction(for: CurationFeature.self) public struct CurationView: View { @@ -145,7 +146,7 @@ private extension CurationView { HStack(spacing: 2) { Image(systemName: "clock") .font(.system(size: 11, weight: .regular)) - Text(durationText(battle.audioDuration)) + Text(battle.audioDuration.roundedMinuteText) .pretendardFont(family: .Medium, size: 12) } .foregroundStyle(.neutral300) @@ -224,11 +225,6 @@ private extension CurationView { .frame(width: 24, height: 24) .background(.secondary200, in: Circle()) } - - func durationText(_ seconds: Int) -> String { - let minutes = max(1, Int((Double(seconds) / 60).rounded())) - return "\(minutes)분" - } } private extension Array { diff --git a/Projects/Shared/Utill/Sources/Extension/Int+.swift b/Projects/Shared/Utill/Sources/Extension/Int+.swift index e3546704..748d27fe 100644 --- a/Projects/Shared/Utill/Sources/Extension/Int+.swift +++ b/Projects/Shared/Utill/Sources/Extension/Int+.swift @@ -14,4 +14,19 @@ public extension Int { formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: self)) ?? "\(self)" } + + /// 재생시간 초 → "X분 Y초". + var durationText: String { + let minutes = self / 60 + let seconds = self % 60 + if minutes > 0, seconds > 0 { return "\(minutes)분 \(seconds)초" } + if minutes > 0 { return "\(minutes)분" } + return "\(seconds)초" + } + + /// 재생시간 초 → 반올림한 분 단위 텍스트. 1분 미만은 "1분". + var roundedMinuteText: String { + let minutes = max(1, Int((Double(self) / 60).rounded())) + return "\(minutes)분" + } } From 7a65491e7f3a61bb339be9b99d8bc0b3a9e63f44 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:52:10 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=88=9C=EC=84=9C=EC=99=80=20=EB=AC=B4=EA=B4=80?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EC=A7=84=EC=98=81=EC=9D=84=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vote-stats 응답 배열 순서가 배틀 상세 옵션 순서와 달라도 상세에서 채운 optionId 기준으로 A/B 요약을 유지한다. --- .../Comment/Reducer/CommentFeature.swift | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 7463f776..b7009e3b 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -599,28 +599,48 @@ extension CommentFeature { fallback: VoteSummary ) -> VoteSummary { guard stats.options.count >= 2 else { return fallback } - let a = stats.options[0] - let b = stats.options[1] + let optionA = matchedStatsOption( + in: stats.options, + fallbackOptionId: fallback.optionA.optionId, + fallbackIndex: 0 + ) + let optionB = matchedStatsOption( + in: stats.options, + fallbackOptionId: fallback.optionB.optionId, + fallbackIndex: 1 + ) return VoteSummary( changeBadgeTitle: fallback.changeBadgeTitle, - optionA: VoteOptionSummary( - // vote-stats optionId 가 0(누락)이면 배틀 상세에서 채운 값(fallback)을 유지 — 그래야 - // 옵션 탭에서 댓글 등록 시 해당 진영 optionId 로 정확히 등록된다. - optionId: a.optionId > 0 ? a.optionId : fallback.optionA.optionId, - label: a.label ?? fallback.optionA.label, - title: a.title.isEmpty ? fallback.optionA.title : a.title, - representative: fallback.optionA.representative, - imageUrl: a.imageUrl ?? fallback.optionA.imageUrl, - percentage: a.ratio - ), - optionB: VoteOptionSummary( - optionId: b.optionId > 0 ? b.optionId : fallback.optionB.optionId, - label: b.label ?? fallback.optionB.label, - title: b.title.isEmpty ? fallback.optionB.title : b.title, - representative: fallback.optionB.representative, - imageUrl: b.imageUrl ?? fallback.optionB.imageUrl, - percentage: b.ratio - ) + optionA: makeOptionSummary(optionA, fallback: fallback.optionA), + optionB: makeOptionSummary(optionB, fallback: fallback.optionB) + ) + } + + private func matchedStatsOption( + in options: [BattleVoteStatsOption], + fallbackOptionId: Int, + fallbackIndex: Int + ) -> BattleVoteStatsOption { + if fallbackOptionId > 0, + let matched = options.first(where: { $0.optionId == fallbackOptionId }) + { + return matched + } + return options[fallbackIndex] + } + + private func makeOptionSummary( + _ option: BattleVoteStatsOption, + fallback: VoteOptionSummary + ) -> VoteOptionSummary { + VoteOptionSummary( + // vote-stats 응답 순서가 배틀 상세와 달라도 fallback optionId 로 A/B 진영을 유지한다. + optionId: option.optionId > 0 ? option.optionId : fallback.optionId, + label: option.label ?? fallback.label, + title: option.title.isEmpty ? fallback.title : option.title, + representative: fallback.representative, + imageUrl: option.imageUrl ?? fallback.imageUrl, + percentage: option.ratio ) } } From 27df5037b62bfa371bf6b8c8efee9c73ca2d4ad1 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 13:52:28 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=EC=84=A0=ED=83=9D=20=ED=83=AD=20?= =?UTF-8?q?=EC=83=89=EC=83=81=EC=9D=84=20=ED=83=AD=EB=B0=94=20appearance?= =?UTF-8?q?=EA=B0=80=20=EA=B2=B0=EC=A0=95=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커스텀 탭 Label에서 gray200을 고정하지 않고 선택/미선택 색상은 UITabBarAppearance와 tint 설정을 따르게 한다. --- Projects/Presentation/MainTab/Sources/View/MainTabView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index ec4f9893..f6f8b0aa 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -39,7 +39,7 @@ public struct MainTabView: View { ) { tabContent(for: $0) } - // 기본(미선택) 색은 라벨 foregroundStyle(gray200), 선택 탭은 tint 로 제어. + // 기본/선택 색은 UITabBarAppearance 와 tint 가 제어. .tint(.neutral900) } } @@ -92,11 +92,9 @@ extension MainTabView { Label { Text(tab.title) .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.gray200) } icon: { tabIcon(for: tab) } - .foregroundStyle(.gray200) } @ViewBuilder