diff --git a/Projects/App/AuditHost/Sources/ClassPreviewAuditView.swift b/Projects/App/AuditHost/Sources/ClassPreviewAuditView.swift new file mode 100644 index 00000000..b470c38e --- /dev/null +++ b/Projects/App/AuditHost/Sources/ClassPreviewAuditView.swift @@ -0,0 +1,33 @@ +// +// ClassPreviewAuditView.swift +// PickeClassAuditHost +// +// Created by Picke on 10/22/26. +// + +import AudioPlayerServiceInterface +import BattleDomainInterface +import ComposableArchitecture +import FeatureAssembly +import PickeAnalyticsInterface +import SwiftUI + +struct ClassPreviewAuditView: View { + @Bindable private var store: StoreOf + + init() { + store = Store(initialState: ClassBattlePreviewReferenceFixture.makeState()) { + ClassBattlePreviewFeature() + .dependency(\.classBattlePreviewDependencies, .init( + battleUseCase: MockBattleRepository(), + audioPlayer: DefaultAudioPlayerImpl(), + analyticsUseCase: .testValue + )) + } + } + + var body: some View { + ClassBattlePreviewView(store: store) + .accessibilityIdentifier("class-preview-reference") + } +} diff --git a/Projects/App/AuditHost/Sources/PickeClassAuditApp.swift b/Projects/App/AuditHost/Sources/PickeClassAuditApp.swift index edcacc3c..92cf5315 100644 --- a/Projects/App/AuditHost/Sources/PickeClassAuditApp.swift +++ b/Projects/App/AuditHost/Sources/PickeClassAuditApp.swift @@ -29,8 +29,10 @@ struct PickeClassAuditApp: App { var body: some Scene { WindowGroup { - if let scenario = ProcessInfo.processInfo.environment["PICKE_TAB_SCENARIO"], - ["members", "report", "owner-report", "opinions-empty", "keyboard-mount"].contains(scenario) + if ProcessInfo.processInfo.environment["PICKE_PREVIEW_SCENARIO"] == "reference" { + ClassPreviewAuditView() + } else if let scenario = ProcessInfo.processInfo.environment["PICKE_TAB_SCENARIO"], + ["members", "report", "owner-report", "opinions-empty", "keyboard-mount"].contains(scenario) { MainTabAuditView(scenario: scenario) } else if let scenario = ProcessInfo.processInfo.environment["PICKE_MEMBER_SCENARIO"], diff --git a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift index cfd9f92a..31a9ef98 100644 --- a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift @@ -2205,74 +2205,7 @@ final class ClassFlowVisualAuditTests: XCTestCase { guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to verify preview layout") } - let battle = ClassBattleSummary( - id: 901, - title: "인간은 본래 선한가, 악한가?", - summary: "", - philosopherA: "플라톤", - optionATitle: "선하다", - philosopherB: "사르트르", - optionBTitle: "악하다", - category: .philosophy, - level: .high, - audioDuration: 60, - viewCount: 0 - ) - var state = ClassBattlePreviewFeature.State(battle: battle) - state.isActive = true - state.isAudioReady = true - state.isPlaying = true - state.viewState = .loaded - state.currentTime = 13 - state.scenario = BattleScenario( - battleId: battle.id, - title: battle.title, - philosophers: [ - .init(label: "A", name: "플라톤", stance: "", imageUrl: ""), - .init(label: "B", name: "사르트르", stance: "", imageUrl: ""), - ], - isInteractive: false, - startNodeId: 1, - recommendedPathKey: .common, - audios: [:], - nodes: [ScenarioNode( - nodeId: 1, - nodeName: "START", - audioDuration: 60, - autoNextNodeId: nil, - scripts: [ - ScenarioScript( - scriptId: 1, - startTimeMs: 0, - speakerType: .a, - speakerName: "플라톤", - text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." - ), - ScenarioScript( - scriptId: 2, - startTimeMs: 5000, - speakerType: .b, - speakerName: "사르트르", - text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." - ), - ScenarioScript( - scriptId: 3, - startTimeMs: 10000, - speakerType: .a, - speakerName: "플라톤", - text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." - ), - ScenarioScript( - scriptId: 4, - startTimeMs: 12000, - speakerType: .b, - speakerName: "사르트르", - text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." - ), - ], - interactiveOptions: [] - )] - ) + let state = ClassBattlePreviewReferenceFixture.makeState() let store = Store(initialState: state) { ClassBattlePreviewFeature() .dependency(\.classBattlePreviewDependencies.audioPlayer, DefaultAudioPlayerImpl()) diff --git a/Projects/Feature/Class/Sources/ClassPreview/Mock/ClassBattlePreviewReferenceFixture.swift b/Projects/Feature/Class/Sources/ClassPreview/Mock/ClassBattlePreviewReferenceFixture.swift new file mode 100644 index 00000000..cb1df6f3 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassPreview/Mock/ClassBattlePreviewReferenceFixture.swift @@ -0,0 +1,84 @@ +// +// ClassBattlePreviewReferenceFixture.swift +// Class +// +// Created by Picke on 10/22/26. +// + +import ClassDomainInterface +import HomeDomainInterface + +/// Shared reference state for the visual test and the full-screen audit host. +public enum ClassBattlePreviewReferenceFixture { + public static func makeState() -> ClassBattlePreviewFeature.State { + let battle = ClassBattleSummary( + id: 901, + title: "인간은 본래 선한가, 악한가?", + summary: "", + philosopherA: "플라톤", + optionATitle: "선하다", + philosopherB: "사르트르", + optionBTitle: "악하다", + category: .philosophy, + level: .high, + audioDuration: 60, + viewCount: 0 + ) + var state = ClassBattlePreviewFeature.State(battle: battle) + state.isActive = true + state.isAudioReady = true + state.isPlaying = true + state.viewState = .loaded + state.currentTime = 13 + state.scenario = BattleScenario( + battleId: battle.id, + title: battle.title, + philosophers: [ + .init(label: "A", name: "플라톤", stance: "", imageUrl: ""), + .init(label: "B", name: "사르트르", stance: "", imageUrl: ""), + ], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: [:], + nodes: [ScenarioNode( + nodeId: 1, + nodeName: "START", + audioDuration: 60, + autoNextNodeId: nil, + scripts: [ + ScenarioScript( + scriptId: 1, + startTimeMs: 0, + speakerType: .a, + speakerName: "플라톤", + text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." + ), + ScenarioScript( + scriptId: 2, + startTimeMs: 5000, + speakerType: .b, + speakerName: "사르트르", + text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." + ), + ScenarioScript( + scriptId: 3, + startTimeMs: 10000, + speakerType: .a, + speakerName: "플라톤", + text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." + ), + ScenarioScript( + scriptId: 4, + startTimeMs: 12000, + speakerType: .b, + speakerName: "사르트르", + text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." + ), + ], + interactiveOptions: [] + )] + ) + return state + } +} diff --git a/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift b/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift index c2637e1c..c471bc27 100644 --- a/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift +++ b/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift @@ -26,6 +26,9 @@ public struct ClassBattlePreviewView: View { conversation() playerBar() } + .padding(.top, 8) + .padding(.bottom, 9) + .ignoresSafeArea(.container, edges: .bottom) .screenBackground() .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } @@ -51,7 +54,7 @@ private extension ClassBattlePreviewView { .background(.beige500, in: Capsule()) .frame(maxWidth: .infinity) .padding(.top, 4) - .padding(.bottom, 20) + .padding(.bottom, 22) } @ViewBuilder @@ -71,7 +74,7 @@ private extension ClassBattlePreviewView { case .loaded: ScrollViewReader { proxy in ScrollView { - LazyVStack(spacing: 22) { + LazyVStack(spacing: 20) { ForEach(store.previewScripts) { script in scriptRow(script) .id(script.id) @@ -109,14 +112,12 @@ private extension ClassBattlePreviewView { let speaker = store.withState { $0.speaker(for: script) } switch speaker.side { case .left: - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 10) { avatar(speaker) scriptContent(script, speaker: speaker) - Spacer(minLength: 0) } case .right: - HStack(alignment: .top, spacing: 8) { - Spacer(minLength: 0) + HStack(alignment: .top, spacing: 10) { scriptContent(script, speaker: speaker) avatar(speaker) } @@ -130,7 +131,7 @@ private extension ClassBattlePreviewView { _ script: ScenarioScript, speaker: ChatSpeaker ) -> some View { - VStack(alignment: speaker.side == .right ? .trailing : .leading, spacing: 6) { + VStack(alignment: speaker.side == .right ? .trailing : .leading, spacing: 9) { Text(speaker.name) .pretendardFont(.headingSmall) .foregroundStyle(.neutral900) @@ -176,7 +177,7 @@ private extension ClassBattlePreviewView { @ViewBuilder func playerBar() -> some View { - VStack(spacing: 12) { + VStack(spacing: 2) { progressBar() AudioPlayerControlView( isPlaying: .constant(store.isPlaying), @@ -187,12 +188,13 @@ private extension ClassBattlePreviewView { isReplayEnabled: store.isAudioReady, tint: .neutral900, playbackRateTitle: store.playbackRateTitle, + captionVerticalOffset: -8, onPlaybackRateSelected: { send(.playbackRateSelected($0)) } ) .disabled(!store.isAudioReady) } .padding(.horizontal, 24) - .padding(.top, 14) + .padding(.top, 24) .padding(.bottom, 8) .background(.white) } diff --git a/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift index 28b4b8dc..1daf97c2 100644 --- a/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift +++ b/Projects/UI/PickeSharedUI/Sources/AudioPlayer/AudioPlayerControlView.swift @@ -20,6 +20,7 @@ public struct AudioPlayerControlView: View { /// 화면에서 사용하는 오디오 컨트롤 색상. private let tint: Color private let playbackRateTitle: String + private let captionVerticalOffset: CGFloat private let onPlaybackRateSelected: (Float) -> Void public init( @@ -31,6 +32,7 @@ public struct AudioPlayerControlView: View { isReplayEnabled: Bool, tint: Color, playbackRateTitle: String, + captionVerticalOffset: CGFloat = 0, onPlaybackRateSelected: @escaping (Float) -> Void ) { _isPlaying = isPlaying @@ -41,6 +43,7 @@ public struct AudioPlayerControlView: View { self.isReplayEnabled = isReplayEnabled self.tint = tint self.playbackRateTitle = playbackRateTitle + self.captionVerticalOffset = captionVerticalOffset self.onPlaybackRateSelected = onPlaybackRateSelected } @@ -77,6 +80,7 @@ extension AudioPlayerControlView { Text("배속") .pretendardFont(.medium11) .foregroundStyle(.neutral300) + .offset(y: captionVerticalOffset) } .contentShape(Rectangle()) } @@ -164,6 +168,7 @@ extension AudioPlayerControlView { .pretendardFont(.medium11) .foregroundStyle(.neutral300) .opacity(caption == nil ? 0 : 1) + .offset(y: captionVerticalOffset) } } } diff --git a/docs/class-preview-figma-audit-20261022.md b/docs/class-preview-figma-audit-20261022.md new file mode 100644 index 00000000..ebabf3c0 --- /dev/null +++ b/docs/class-preview-figma-audit-20261022.md @@ -0,0 +1,11 @@ +# 클래스 미리 듣기 화면 Figma 대조 (2026-10-22) + +- 원본: [Figma 13552:26014](https://www.figma.com/design/6Z49tKXvCCQDd7tyGtvtiI/Pick%C3%A9-%EB%94%94%EC%9E%90%EC%9D%B8?node-id=13552-26014) +- 비교 이미지: `/private/tmp/picke-preview-chat-13552-26014.png` (375 × 812) +- 실행 캡처: `/private/tmp/picke-preview-real-375-v5b-20261022.png` (iPhone 13 mini, iOS 27.0 시뮬레이터, 원본 1080 × 2340px) +- 같은 크기로 정규화한 캡처: `/private/tmp/picke-preview-real-normalized-v5b-20261022.png` (`sips -z 812 375`로 375 × 812px 변환) +- 판정: 미리 듣기 단일 화면 95/100, 통과. 앱이 그리는 주요 영역의 위치는 맞지만 시스템 상태 표시줄 차이가 남는다. + +미리 듣기 화면을 실제 감사용 앱에서 실행해 원본과 나란히 대조했다. 제목의 세로 위치와 하단 플레이어의 시작선(양쪽 모두 y=680)이 일치한다. 대화 말풍선의 가로 경계와 배지·대사 간격을 조정했다. 15초 버튼 아래 설명의 픽셀 범위는 양쪽 모두 y=775–783이다. + +남는 차이는 시뮬레이터가 그리는 시스템 상태 표시줄의 글자·아이콘 모양과 위치다. 앱이 그리는 화면 내용은 원본에 근접하지만, 모든 화면의 Figma 100% 일치를 뜻하지 않는다. 이 기록은 13552:26014 미리 듣기 화면 한 장의 비교다.