feat(main-home): 추천 홈 공용 컴포넌트를 추가한다

This commit is contained in:
Yu Sung
2026-06-12 15:08:22 +09:00
parent 016a8bcca3
commit ed5e92e1d6
15 changed files with 748 additions and 4 deletions

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_community_lock.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_follow.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_following.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct BannerCarouselItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
init(id: String, imageUrl: String?) {
self.id = id
self.imageUrl = imageUrl
}
}
struct BannerCarousel: View {
let items: [BannerCarouselItem]
let height: CGFloat
let action: (BannerCarouselItem) -> Void
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = items
self.height = height
self.action = action
}
var body: some View {
if !items.isEmpty {
TabView {
ForEach(items) { item in
Button {
action(item)
} label: {
DownsampledKFImage(
url: URL(string: item.imageUrl ?? ""),
size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
}
}
.frame(height: height)
.tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never))
}
}
}
struct BannerCarousel_Previews: PreviewProvider {
static var previews: some View {
BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)])
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct FollowAllButton: View {
let isCompleted: Bool
let isLoading: Bool
let action: () -> Void
init(
isCompleted: Bool,
isLoading: Bool = false,
action: @escaping () -> Void
) {
self.isCompleted = isCompleted
self.isLoading = isLoading
self.action = action
}
var body: some View {
Button {
if !isCompleted && !isLoading {
action()
}
} label: {
HStack(spacing: SodaSpacing.s6) {
if isCompleted {
Image("ic_new_following")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 16, height: 16)
}
Text(isCompleted ? I18n.HomeRecommendation.followAllCompleted : I18n.HomeRecommendation.followAll)
.appFont(.body5)
.foregroundColor(.white)
}
.padding(.horizontal, SodaSpacing.s16)
.frame(height: 36)
.background(isCompleted ? Color.gray700 : Color.button)
.clipShape(Capsule())
.opacity(isLoading ? 0.6 : 1)
}
.buttonStyle(.plain)
.disabled(isCompleted || isLoading)
}
}
struct FollowAllButton_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s12) {
FollowAllButton(isCompleted: false) {}
FollowAllButton(isCompleted: true) {}
}
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct AiCharacterCard: View {
let name: String
let description: String
let profileImageUrl: String?
let chatCount: Int?
let originalTitle: String?
var body: some View {
HStack(alignment: .top, spacing: SodaSpacing.s12) {
DownsampledKFImage(url: URL(string: profileImageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(name)
.appFont(.heading4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(description)
.appFont(.body5)
.foregroundColor(Color.gray500)
.lineLimit(2)
.truncationMode(.tail)
HStack(spacing: SodaSpacing.s8) {
if let chatCount {
Text("Chat \(chatCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let originalTitle, !originalTitle.isEmpty {
Text(originalTitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
}
struct AiCharacterCard_Previews: PreviewProvider {
static var previews: some View {
AiCharacterCard(
name: "AI 캐릭터",
description: "캐릭터 설명이 표시됩니다.",
profileImageUrl: nil,
chatCount: 128,
originalTitle: "원작"
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
struct CommunityPostCard: View {
let creatorNickname: String
let creatorProfileImageUrl: String?
let content: String
let imageUrl: String?
let price: Int?
let existOrdered: Bool
let createdAt: String?
let likeCount: Int?
let commentCount: Int?
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
header
Text(content)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(3)
.truncationMode(.tail)
if hasImage {
imageContent
}
footer
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 32, height: 32))
.background(Color.gray800)
.clipShape(Circle())
Text(creatorNickname)
.appFont(.body5)
.foregroundColor(.white)
.lineLimit(1)
Spacer(minLength: 0)
}
}
private var imageContent: some View {
ZStack {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 240, height: 150))
.background(Color.gray800)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s12, style: .continuous))
.blur(radius: isLocked ? 8 : 0)
if isLocked {
VStack(spacing: SodaSpacing.s6) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
if let price {
Text("\(price) can")
.appFont(.caption2)
.foregroundColor(Color.button)
.padding(.horizontal, SodaSpacing.s12)
.padding(.vertical, SodaSpacing.s6)
.overlay(
Capsule()
.stroke(Color.button, lineWidth: 1)
)
}
}
}
}
}
private var footer: some View {
HStack(spacing: SodaSpacing.s12) {
if let createdAt, !createdAt.isEmpty {
Text(createdAt)
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let likeCount {
Text("Like \(likeCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let commentCount {
Text("Comment \(commentCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
}
}
private var hasImage: Bool {
!(imageUrl ?? "").isEmpty
}
private var isLocked: Bool {
(price ?? 0) > 0 && !existOrdered
}
}
struct CommunityPostCard_Previews: PreviewProvider {
static var previews: some View {
CommunityPostCard(
creatorNickname: "크리에이터",
creatorProfileImageUrl: nil,
content: "커뮤니티 본문입니다.",
imageUrl: nil,
price: nil,
existOrdered: false,
createdAt: "방금 전",
likeCount: 10,
commentCount: 2
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct CreatorProfileGridItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
let name: String
let subtitle: String?
init(
id: String,
imageUrl: String?,
name: String,
subtitle: String? = nil
) {
self.id = id
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
}
}
struct CreatorProfileGrid: View {
let items: [CreatorProfileGridItem]
let columns: Int
let spacing: CGFloat
let action: (CreatorProfileGridItem) -> Void
init(
items: [CreatorProfileGridItem],
columns: Int = 3,
spacing: CGFloat = SodaSpacing.s16,
action: @escaping (CreatorProfileGridItem) -> Void = { _ in }
) {
self.items = items
self.columns = columns
self.spacing = spacing
self.action = action
}
var body: some View {
LazyVGrid(columns: gridColumns, alignment: .center, spacing: spacing) {
ForEach(items) { item in
CreatorProfileItem(
imageUrl: item.imageUrl,
name: item.name,
subtitle: item.subtitle
) {
action(item)
}
}
}
}
private var gridColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns)
}
}
struct CreatorProfileGrid_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileGrid(items: [
CreatorProfileGridItem(id: "1", imageUrl: nil, name: "크리에이터 1"),
CreatorProfileGridItem(id: "2", imageUrl: nil, name: "크리에이터 2"),
CreatorProfileGridItem(id: "3", imageUrl: nil, name: "크리에이터 3")
])
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct CreatorProfileItem: View {
let imageUrl: String?
let name: String
let subtitle: String?
let action: (() -> Void)?
init(
imageUrl: String?,
name: String,
subtitle: String? = nil,
action: (() -> Void)? = nil
) {
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
self.action = action
}
var body: some View {
Button {
action?()
} label: {
VStack(alignment: .center, spacing: SodaSpacing.s8) {
profileImage
VStack(alignment: .center, spacing: 2) {
Text(name)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(action == nil)
}
private var profileImage: some View {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(Circle())
}
}
struct CreatorProfileItem_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileItem(imageUrl: nil, name: "크리에이터", subtitle: "방금 활동")
.frame(width: 96)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
struct ExpandableTextView: View {
let text: String
let lineLimit: Int
let moreTitle: String
let collapseTitle: String
let font: SodaTypography
let foregroundColor: Color
@State private var isExpanded = false
@State private var fullTextHeight: CGFloat = 0
@State private var limitedTextHeight: CGFloat = 0
init(
text: String,
lineLimit: Int = 3,
moreTitle: String = I18n.HomeRecommendation.more,
collapseTitle: String = I18n.HomeRecommendation.collapse,
font: SodaTypography = .body3,
foregroundColor: Color = Color.gray500
) {
self.text = text
self.lineLimit = lineLimit
self.moreTitle = moreTitle
self.collapseTitle = collapseTitle
self.font = font
self.foregroundColor = foregroundColor
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
Text(text)
.appFont(font)
.foregroundColor(foregroundColor)
.lineLimit(isExpanded ? nil : lineLimit)
.background(measuringText(lineLimit: nil, height: $fullTextHeight))
.background(measuringText(lineLimit: lineLimit, height: $limitedTextHeight))
if shouldShowToggle {
Button {
isExpanded.toggle()
} label: {
Text(isExpanded ? collapseTitle : moreTitle)
.appFont(.body5)
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
}
private var shouldShowToggle: Bool {
fullTextHeight > limitedTextHeight + 1
}
private func measuringText(lineLimit: Int?, height: Binding<CGFloat>) -> some View {
Text(text)
.appFont(font)
.lineLimit(lineLimit)
.fixedSize(horizontal: false, vertical: true)
.hidden()
.background(
GeometryReader { proxy in
Color.clear
.onAppear { height.wrappedValue = proxy.size.height }
.onChange(of: proxy.size.height) { newHeight in height.wrappedValue = newHeight }
.onChange(of: text) { _ in height.wrappedValue = proxy.size.height }
}
)
}
}
struct ExpandableTextView_Previews: PreviewProvider {
static var previews: some View {
ExpandableTextView(text: "긴 사업자 정보 텍스트가 들어가는 영역입니다. 세 줄을 넘어가면 더보기 버튼이 표시되고, 더보기 버튼을 누르면 전체 문구가 표시됩니다.")
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
import Combine
final class MainHomeViewModel: ObservableObject {
private let repository = MainHomeRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var isShowPopup = false
@Published var errorMessage = ""
@Published var recommendations: HomeRecommendationResponse?
@Published private(set) var completedFollowKeys = Set<String>()
@Published private(set) var followingKeys = Set<String>()
func fetchRecommendations() {
isLoading = true
repository.getRecommendations()
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<HomeRecommendationResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.recommendations = data
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func followAll(creatorIds: [Int], completionKey: String) {
guard !creatorIds.isEmpty, !completionKey.isEmpty else { return }
guard !completedFollowKeys.contains(completionKey), !followingKeys.contains(completionKey) else { return }
followingKeys.insert(completionKey)
repository.followRecommendedCreators(request: FollowRecommendedCreatorsRequest(creatorIds: creatorIds))
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.followingKeys.remove(completionKey)
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
self.completedFollowKeys.insert(completionKey)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.followingKeys.remove(completionKey)
}
.store(in: &subscription)
}
func isFollowAllCompleted(_ completionKey: String) -> Bool {
completedFollowKeys.contains(completionKey)
}
func isFollowAllLoading(_ completionKey: String) -> Bool {
followingKeys.contains(completionKey)
}
}

View File

@@ -162,7 +162,7 @@
### Phase 2: ViewModel 상태와 공용 UI 컴포넌트 작성 ### Phase 2: ViewModel 상태와 공용 UI 컴포넌트 작성
- [ ] **Task 2.1: MainHomeViewModel 작성** - [x] **Task 2.1: MainHomeViewModel 작성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift` - 생성: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
@@ -186,7 +186,7 @@
- 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다. - 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다.
- 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다. - 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다.
- [ ] **Task 2.2: 공용 `ExpandableTextView` 작성** - [x] **Task 2.2: 공용 `ExpandableTextView` 작성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` - 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
- 작업 내용: - 작업 내용:
@@ -201,7 +201,7 @@
- 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다. - 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다.
- 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다. - 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다.
- [ ] **Task 2.3: 공용 Creator 컴포넌트 작성** - [x] **Task 2.3: 공용 Creator 컴포넌트 작성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift` - 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift` - 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
@@ -215,7 +215,7 @@
- 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다. - 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다.
- 수동 확인: 공용 컴포넌트 타입명에 `MainHome` 또는 `HomeRecommendation` 접두사를 붙이지 않아야 한다. - 수동 확인: 공용 컴포넌트 타입명에 `MainHome` 또는 `HomeRecommendation` 접두사를 붙이지 않아야 한다.
- [ ] **Task 2.4: 공용 Button/Banner/Card 컴포넌트 작성** - [x] **Task 2.4: 공용 Button/Banner/Card 컴포넌트 작성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift` - 생성: `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift`
- 생성: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` - 생성: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
@@ -412,3 +412,49 @@
- 아직 수행하지 않은 작업: - 아직 수행하지 않은 작업:
- Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결 - Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결
- 테스트 타깃이 없어 Phase 1 전용 RED/GREEN 단위 테스트는 추가하지 않음 - 테스트 타깃이 없어 Phase 1 전용 RED/GREEN 단위 테스트는 추가하지 않음
### 2026-06-02 Phase 2 구현 완료
- 목적: Phase 2 범위인 `MainHomeViewModel`과 공용 UI 컴포넌트 작성
- 수행 내용:
- `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`에 추천 API 로딩/오류/응답 상태와 모두 팔로우 완료/호출 중 상태 추가
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 추가
- `SodaLive/Sources/V2/Component/Creator``CreatorProfileItem`, `CreatorProfileGrid` 추가
- `SodaLive/Sources/V2/Component/Button`, `Banner`, `Card``FollowAllButton`, `BannerCarousel`, `AiCharacterCard`, `CommunityPostCard` 추가
- `SodaLive/Resources/Assets.xcassets/v2`에 Phase 2 버튼/카드용 `ic_new_following`, `ic_new_follow`, `ic_new_community_lock` 이미지셋 포함
- 신규 Swift 파일 8개를 `SodaLive.xcodeproj/project.pbxproj``SodaLive`, `SodaLive-dev` Sources에 포함
- 실제 파일이 없던 기존 `CustomView/ExpandableTextView.swift` stale 프로젝트 참조를 제거해 중복 빌드 산출물 오류 해결
- 검증:
- `rg "final class MainHomeViewModel|fetchRecommendations|followAll|ApiResponse<HomeRecommendationResponse>|ApiResponseWithoutData" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift` 실행, ViewModel 상태/API 처리 메서드 검색 확인
- `rg "struct ExpandableTextView|lineLimit|moreTitle|collapseTitle|isExpanded" SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 실행, 확장/접기 상태와 3줄 제한 구현 키워드 검색 확인
- `rg "struct CreatorProfileItem|struct CreatorProfileGrid|creatorNickname|HomeCreatorItem|HomeRecommendationResponse|MainHome" SodaLive/Sources/V2/Component/Creator` 실행, 공용 Creator 컴포넌트 검색 및 API 응답 타입 의존 없음 확인
- `rg "struct FollowAllButton|ic_new_following|struct BannerCarousel|struct AiCharacterCard|struct CommunityPostCard|구매완료|HomeBannerItem|HomeAiCharacterItem|HomePopularCommunityPostItem|HomeRecommendationResponse" SodaLive/Sources/V2/Component` 실행, 공용 컴포넌트 검색 및 금지 문구/응답 타입 의존 없음 확인
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인
- 아직 수행하지 않은 작업:
- Phase 3 이후 MainHome 전용 섹션, 페이지 조립, 홈 탭 연결
- 테스트 타깃이 없어 Phase 2 전용 RED/GREEN 단위 테스트는 추가하지 않음
### 2026-06-12 Phase 2 코드 리뷰 보완
- 목적: Phase 2 코드 리뷰에서 확인된 ViewModel Combine 캡처 안정성과 커뮤니티 카드 잠금 아이콘 asset 불일치 보완
- 수행 내용:
- `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`의 추천 API/모두 팔로우 API Combine callback 캡처를 `[unowned self]`에서 `[weak self]`와 early return으로 변경
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`의 잠금 아이콘을 기존 `ic_lock_bb`에서 Phase 2 신규 asset인 `ic_new_community_lock`으로 변경
- 검증:
- `rg "unowned self" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, 검색 결과 없음 확인
- `rg "weak self|ic_new_community_lock|ic_lock_bb" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, `weak self` 캡처 4곳과 `ic_new_community_lock` 사용 확인
- `git diff --check` 실행, 출력 없이 성공 확인
### 2026-06-12 Phase 1/2 리뷰 추가 보완
- 목적: Phase 1/2 리뷰에서 확인된 커뮤니티 카드 PRD 표시 누락과 사업자 정보 더보기 판정 안정성 보완
- 수행 내용:
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`에 생성 시간 표시용 `createdAt` 입력과 footer 렌더링 추가
- 잠금 이미지의 가격 표시를 단순 텍스트에서 pay capsule 형태로 변경
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`의 측정 높이 변화 감지를 추가해 부모 폭/레이아웃 변경 시 더보기 표시 판정이 갱신되도록 보완
- 검증:
- `rg "createdAt|Capsule\(\)|onChange\(of: proxy.size.height\)" SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 실행, 생성 시간 입력/표시, pay capsule, 높이 변화 감지 검색 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인