diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/Contents.json new file mode 100644 index 0000000..8f8f3c2 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/ic_new_community_lock.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/ic_new_community_lock.png new file mode 100644 index 0000000..8578e6c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/ic_new_community_lock.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/Contents.json new file mode 100644 index 0000000..a8d78a5 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/ic_new_follow.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/ic_new_follow.png new file mode 100644 index 0000000..96aeffc Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/ic_new_follow.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/Contents.json new file mode 100644 index 0000000..548e174 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/ic_new_following.png b/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/ic_new_following.png new file mode 100644 index 0000000..eb5eb62 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/ic_new_following.png differ diff --git a/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift new file mode 100644 index 0000000..1a41b67 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Button/FollowAllButton.swift b/SodaLive/Sources/V2/Component/Button/FollowAllButton.swift new file mode 100644 index 0000000..0537f33 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Button/FollowAllButton.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift b/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift new file mode 100644 index 0000000..5613d89 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift b/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift new file mode 100644 index 0000000..11037a9 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift b/SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift new file mode 100644 index 0000000..17ffc0a --- /dev/null +++ b/SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift b/SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift new file mode 100644 index 0000000..3cab80a --- /dev/null +++ b/SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift b/SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift new file mode 100644 index 0000000..ab6fe62 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift @@ -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) -> 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) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift b/SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift new file mode 100644 index 0000000..5c7c31c --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift @@ -0,0 +1,107 @@ +import Foundation +import Combine + +final class MainHomeViewModel: ObservableObject { + private let repository = MainHomeRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var isShowPopup = false + @Published var errorMessage = "" + @Published var recommendations: HomeRecommendationResponse? + @Published private(set) var completedFollowKeys = Set() + @Published private(set) var followingKeys = Set() + + 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.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) + } +} diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 3fb1daf..49327f8 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -162,7 +162,7 @@ ### 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/Repository/MainHomeRepository.swift` @@ -186,7 +186,7 @@ - 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다. - 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다. -- [ ] **Task 2.2: 공용 `ExpandableTextView` 작성** +- [x] **Task 2.2: 공용 `ExpandableTextView` 작성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` - 작업 내용: @@ -201,7 +201,7 @@ - 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다. - 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다. -- [ ] **Task 2.3: 공용 Creator 컴포넌트 작성** +- [x] **Task 2.3: 공용 Creator 컴포넌트 작성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift` - 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift` @@ -215,7 +215,7 @@ - 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다. - 수동 확인: 공용 컴포넌트 타입명에 `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/Banner/BannerCarousel.swift` @@ -412,3 +412,49 @@ - 아직 수행하지 않은 작업: - Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결 - 테스트 타깃이 없어 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|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` 확인