Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Projects/App/AuditHost/Sources/ClassPreviewAuditView.swift
Original file line number Diff line number Diff line change
@@ -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<ClassBattlePreviewFeature>

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")
}
}
6 changes: 4 additions & 2 deletions Projects/App/AuditHost/Sources/PickeClassAuditApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
69 changes: 1 addition & 68 deletions Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -51,7 +54,7 @@ private extension ClassBattlePreviewView {
.background(.beige500, in: Capsule())
.frame(maxWidth: .infinity)
.padding(.top, 4)
.padding(.bottom, 20)
.padding(.bottom, 22)
}

@ViewBuilder
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -176,7 +177,7 @@ private extension ClassBattlePreviewView {

@ViewBuilder
func playerBar() -> some View {
VStack(spacing: 12) {
VStack(spacing: 2) {
progressBar()
AudioPlayerControlView(
isPlaying: .constant(store.isPlaying),
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -31,6 +32,7 @@ public struct AudioPlayerControlView: View {
isReplayEnabled: Bool,
tint: Color,
playbackRateTitle: String,
captionVerticalOffset: CGFloat = 0,
onPlaybackRateSelected: @escaping (Float) -> Void
) {
_isPlaying = isPlaying
Expand All @@ -41,6 +43,7 @@ public struct AudioPlayerControlView: View {
self.isReplayEnabled = isReplayEnabled
self.tint = tint
self.playbackRateTitle = playbackRateTitle
self.captionVerticalOffset = captionVerticalOffset
self.onPlaybackRateSelected = onPlaybackRateSelected
}

Expand Down Expand Up @@ -77,6 +80,7 @@ extension AudioPlayerControlView {
Text("배속")
.pretendardFont(.medium11)
.foregroundStyle(.neutral300)
.offset(y: captionVerticalOffset)
}
.contentShape(Rectangle())
}
Expand Down Expand Up @@ -164,6 +168,7 @@ extension AudioPlayerControlView {
.pretendardFont(.medium11)
.foregroundStyle(.neutral300)
.opacity(caption == nil ? 0 : 1)
.offset(y: captionVerticalOffset)
}
}
}
11 changes: 11 additions & 0 deletions docs/class-preview-figma-audit-20261022.md
Original file line number Diff line number Diff line change
@@ -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 미리 듣기 화면 한 장의 비교다.
Loading