feat(creator): 커뮤니티 아이템 표시를 통일한다
This commit is contained in:
@@ -3,26 +3,22 @@ import SwiftUI
|
|||||||
struct CreatorChannelCommunityGridItem: View {
|
struct CreatorChannelCommunityGridItem: View {
|
||||||
let post: CreatorChannelCommunityPostItem
|
let post: CreatorChannelCommunityPostItem
|
||||||
let isOwnPost: Bool
|
let isOwnPost: Bool
|
||||||
let onTapPurchase: () -> Void
|
|
||||||
let onTapDetail: () -> Void
|
let onTapDetail: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
ZStack {
|
ZStack {
|
||||||
if isPaidLocked {
|
if isPaidLocked {
|
||||||
Button(action: onTapPurchase) {
|
Color.gray800
|
||||||
Color.gray400
|
.overlay(paidPriceOverlay)
|
||||||
.overlay(paidPriceOverlay)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
} else {
|
} else {
|
||||||
tileContent(proxy: proxy)
|
tileContent(proxy: proxy)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture(perform: onTapDetail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: proxy.size.width, height: proxy.size.width)
|
.frame(width: proxy.size.width, height: proxy.size.width)
|
||||||
.clipped()
|
.clipped()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onTapDetail)
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if post.isPinned {
|
if post.isPinned {
|
||||||
Image("ic_pin")
|
Image("ic_pin")
|
||||||
@@ -92,3 +88,32 @@ struct CreatorChannelCommunityGridItem: View {
|
|||||||
return String(content.prefix(18))
|
return String(content.prefix(18))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityGridItem_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
CreatorChannelCommunityGridItem(
|
||||||
|
post: CreatorChannelCommunityPostItem(
|
||||||
|
postId: 1,
|
||||||
|
creatorId: 10,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileUrl: "https://picsum.photos/600",
|
||||||
|
createdAtUtc: "2026-08-10T00:00:00Z",
|
||||||
|
content: "유료 커뮤니티 게시글",
|
||||||
|
imageUrl: nil,
|
||||||
|
audioUrl: nil,
|
||||||
|
price: 30,
|
||||||
|
isCommentAvailable: true,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 2,
|
||||||
|
isPinned: true,
|
||||||
|
existOrdered: false,
|
||||||
|
isLiked: false
|
||||||
|
),
|
||||||
|
isOwnPost: false,
|
||||||
|
onTapDetail: {}
|
||||||
|
)
|
||||||
|
.frame(width: 134, height: 134)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
let isOwnCreatorChannel: Bool
|
let isOwnCreatorChannel: Bool
|
||||||
let onTapLike: () -> Void
|
let onTapLike: () -> Void
|
||||||
let onTapMore: () -> Void
|
let onTapMore: () -> Void
|
||||||
let onTapPurchase: () -> Void
|
|
||||||
let onTapDetail: () -> Void
|
let onTapDetail: () -> Void
|
||||||
|
|
||||||
@State private var localIsLike: Bool
|
@State private var localIsLike: Bool
|
||||||
@@ -23,7 +22,6 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
isOwnCreatorChannel: Bool,
|
isOwnCreatorChannel: Bool,
|
||||||
onTapLike: @escaping () -> Void,
|
onTapLike: @escaping () -> Void,
|
||||||
onTapMore: @escaping () -> Void,
|
onTapMore: @escaping () -> Void,
|
||||||
onTapPurchase: @escaping () -> Void,
|
|
||||||
onTapDetail: @escaping () -> Void
|
onTapDetail: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.post = post
|
self.post = post
|
||||||
@@ -31,7 +29,6 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
self.isOwnCreatorChannel = isOwnCreatorChannel
|
self.isOwnCreatorChannel = isOwnCreatorChannel
|
||||||
self.onTapLike = onTapLike
|
self.onTapLike = onTapLike
|
||||||
self.onTapMore = onTapMore
|
self.onTapMore = onTapMore
|
||||||
self.onTapPurchase = onTapPurchase
|
|
||||||
self.onTapDetail = onTapDetail
|
self.onTapDetail = onTapDetail
|
||||||
_localIsLike = State(initialValue: post.isLiked ?? false)
|
_localIsLike = State(initialValue: post.isLiked ?? false)
|
||||||
_localLikeCount = State(initialValue: post.likeCount)
|
_localLikeCount = State(initialValue: post.likeCount)
|
||||||
@@ -53,7 +50,9 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
imageContent(imageUrl: imageUrl)
|
imageContent(imageUrl: imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
reactionBar
|
if !isPaidLocked {
|
||||||
|
reactionBar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(SodaSpacing.s14)
|
.padding(SodaSpacing.s14)
|
||||||
@@ -172,14 +171,11 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var paidLockedImage: some View {
|
private var paidLockedImage: some View {
|
||||||
Button(action: onTapPurchase) {
|
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||||
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
.fill(Color.gray800)
|
||||||
.fill(Color.gray400)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(height: 236)
|
||||||
.frame(height: 236)
|
.overlay(paidPriceOverlay)
|
||||||
.overlay(paidPriceOverlay)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var paidPriceOverlay: some View {
|
private var paidPriceOverlay: some View {
|
||||||
@@ -219,7 +215,7 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
|
|
||||||
private var reactionBar: some View {
|
private var reactionBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
if post.isCommentAvailable && !isPaidLocked {
|
if post.isCommentAvailable {
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
Image("ic_feed_community_reply")
|
Image("ic_feed_community_reply")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -231,25 +227,23 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPaidLocked {
|
Button {
|
||||||
Button {
|
localIsLike.toggle()
|
||||||
localIsLike.toggle()
|
localLikeCount += localIsLike ? 1 : -1
|
||||||
localLikeCount += localIsLike ? 1 : -1
|
localLikeCount = max(0, localLikeCount)
|
||||||
localLikeCount = max(0, localLikeCount)
|
onTapLike()
|
||||||
onTapLike()
|
} label: {
|
||||||
} label: {
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||||
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
.resizable()
|
||||||
.resizable()
|
.frame(width: 18, height: 18)
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(localLikeCount)")
|
Text("\(localLikeCount)")
|
||||||
.appFont(size: 16, weight: .regular)
|
.appFont(size: 16, weight: .regular)
|
||||||
.foregroundColor(Color.gray400)
|
.foregroundColor(Color.gray400)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
let creatorId: Int
|
let creatorId: Int
|
||||||
let isOwnCreatorChannel: Bool
|
let isOwnCreatorChannel: Bool
|
||||||
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
|
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
|
||||||
let onTapDetail: (CreatorChannelCommunityPostItem) -> Void
|
let onTapDetail: (Int) -> Void
|
||||||
|
|
||||||
private let gridColumns = [
|
private let gridColumns = [
|
||||||
GridItem(.flexible(), spacing: 0),
|
GridItem(.flexible(), spacing: 0),
|
||||||
@@ -69,11 +69,8 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
onTapMore: {
|
onTapMore: {
|
||||||
viewModel.openReportMenu(post: post)
|
viewModel.openReportMenu(post: post)
|
||||||
},
|
},
|
||||||
onTapPurchase: {
|
|
||||||
viewModel.openPurchaseDialog(post: post)
|
|
||||||
},
|
|
||||||
onTapDetail: {
|
onTapDetail: {
|
||||||
onTapDetail(post)
|
onTapDetail(post.postId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -95,11 +92,8 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
CreatorChannelCommunityGridItem(
|
CreatorChannelCommunityGridItem(
|
||||||
post: post,
|
post: post,
|
||||||
isOwnPost: isOwnPost,
|
isOwnPost: isOwnPost,
|
||||||
onTapPurchase: {
|
|
||||||
viewModel.openPurchaseDialog(post: post)
|
|
||||||
},
|
|
||||||
onTapDetail: {
|
onTapDetail: {
|
||||||
onTapDetail(post)
|
onTapDetail(post.postId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -50,16 +50,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published var isShowPostPurchaseView = false {
|
|
||||||
didSet {
|
|
||||||
if !isShowPostPurchaseView {
|
|
||||||
selectedPostId = 0
|
|
||||||
selectedPostPrice = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published var selectedPostId = 0
|
@Published var selectedPostId = 0
|
||||||
@Published var selectedPostPrice = 0
|
|
||||||
@Published var selectedPostIsPinned = false
|
@Published var selectedPostIsPinned = false
|
||||||
@Published var isShowSecret = false
|
@Published var isShowSecret = false
|
||||||
|
|
||||||
@@ -94,17 +85,10 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
|
|
||||||
func openReportMenu(post: CreatorChannelCommunityPostItem) {
|
func openReportMenu(post: CreatorChannelCommunityPostItem) {
|
||||||
selectedPostId = post.postId
|
selectedPostId = post.postId
|
||||||
selectedPostPrice = post.price
|
|
||||||
selectedPostIsPinned = post.isPinned
|
selectedPostIsPinned = post.isPinned
|
||||||
isShowReportMenu = true
|
isShowReportMenu = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
|
|
||||||
selectedPostId = post.postId
|
|
||||||
selectedPostPrice = post.price
|
|
||||||
isShowPostPurchaseView = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func communityPostLike(postId: Int) {
|
func communityPostLike(postId: Int) {
|
||||||
communityRepository.communityPostLike(postId: postId)
|
communityRepository.communityPostLike(postId: postId)
|
||||||
.sink { result in
|
.sink { result in
|
||||||
@@ -153,7 +137,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteCommunityPost(creatorId: Int) {
|
func deleteCommunityPost(onSuccess: @escaping () -> Void) {
|
||||||
guard selectedPostId > 0 else { return }
|
guard selectedPostId > 0 else { return }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -190,7 +174,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
if decoded.success {
|
if decoded.success {
|
||||||
self.errorMessage = I18n.Explorer.deleted
|
self.errorMessage = I18n.Explorer.deleted
|
||||||
self.isShowPopup = true
|
self.isShowPopup = true
|
||||||
self.fetchFirstPage(creatorId: creatorId)
|
onSuccess()
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
self.isShowPopup = true
|
self.isShowPopup = true
|
||||||
@@ -204,7 +188,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateCommunityPostFixed(creatorId: Int) {
|
func updateCommunityPostFixed(onSuccess: @escaping () -> Void) {
|
||||||
guard selectedPostId > 0 else { return }
|
guard selectedPostId > 0 else { return }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -228,7 +212,7 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||||
if decoded.success {
|
if decoded.success {
|
||||||
self.fetchFirstPage(creatorId: creatorId)
|
onSuccess()
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
self.isShowPopup = true
|
self.isShowPopup = true
|
||||||
@@ -242,43 +226,6 @@ final class CreatorChannelCommunityViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func purchaseCommunityPost(creatorId: Int) {
|
|
||||||
guard selectedPostId > 0 else { return }
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
communityRepository.purchaseCommunityPost(postId: selectedPostId)
|
|
||||||
.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.applyFailureState()
|
|
||||||
}
|
|
||||||
} receiveValue: { [weak self] response in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isLoading = false
|
|
||||||
self.selectedPostId = 0
|
|
||||||
|
|
||||||
do {
|
|
||||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
|
||||||
if decoded.success {
|
|
||||||
self.fetchFirstPage(creatorId: creatorId)
|
|
||||||
} else {
|
|
||||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
|
||||||
self.isShowPopup = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
ERROR_LOG(error.localizedDescription)
|
|
||||||
self.fetchFirstPage(creatorId: creatorId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
|
private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
|
||||||
latestRequestId += 1
|
latestRequestId += 1
|
||||||
let requestId = latestRequestId
|
let requestId = latestRequestId
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
mediaContent
|
mediaContent
|
||||||
reactionBar
|
if !isPaidLocked {
|
||||||
|
reactionBar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(SodaSpacing.s14)
|
.padding(SodaSpacing.s14)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -154,32 +156,30 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
|||||||
|
|
||||||
private var reactionBar: some View {
|
private var reactionBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
if isPaidLocked == false {
|
if detail.isCommentAvailable {
|
||||||
if detail.isCommentAvailable {
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
Image("ic_feed_community_reply")
|
||||||
Image("ic_feed_community_reply")
|
.resizable()
|
||||||
.resizable()
|
.frame(width: 18, height: 18)
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(detail.commentCount)")
|
Text("\(detail.commentCount)")
|
||||||
.appFont(size: 16, weight: .regular)
|
.appFont(size: 16, weight: .regular)
|
||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: onTapLike) {
|
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
|
||||||
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(detail.likeCount)")
|
|
||||||
.appFont(size: 16, weight: .regular)
|
|
||||||
.foregroundColor(Color.gray500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: onTapLike) {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(detail.likeCount)")
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct CreatorChannelCommunityPostDetailView: View {
|
|||||||
CreatorChannelCommunityPostDetailContentView(
|
CreatorChannelCommunityPostDetailContentView(
|
||||||
detail: detail,
|
detail: detail,
|
||||||
onTapLike: viewModel.toggleLike,
|
onTapLike: viewModel.toggleLike,
|
||||||
onTapPurchase: showPurchaseError
|
onTapPurchase: viewModel.presentPurchaseDialog
|
||||||
)
|
)
|
||||||
|
|
||||||
if detail.isCommentAvailable {
|
if detail.isCommentAvailable {
|
||||||
@@ -69,6 +69,14 @@ struct CreatorChannelCommunityPostDetailView: View {
|
|||||||
onDimmedTap: dismissDeleteDialog
|
onDimmedTap: dismissDeleteDialog
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.isShowPurchaseDialog, let detail = viewModel.detail {
|
||||||
|
CommunityPostPurchaseDialog(
|
||||||
|
isShowing: $viewModel.isShowPurchaseDialog,
|
||||||
|
can: detail.price,
|
||||||
|
confirmAction: viewModel.purchaseCommunityPost
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -112,9 +120,4 @@ struct CreatorChannelCommunityPostDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showPurchaseError() {
|
|
||||||
viewModel.errorMessage = I18n.Common.commonError
|
|
||||||
viewModel.isShowPopup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
|
|||||||
@Published var editingComment: CreatorChannelCommunityCommentResponse?
|
@Published var editingComment: CreatorChannelCommunityCommentResponse?
|
||||||
@Published var deletingComment: CreatorChannelCommunityCommentResponse?
|
@Published var deletingComment: CreatorChannelCommunityCommentResponse?
|
||||||
@Published var isShowDeleteDialog = false
|
@Published var isShowDeleteDialog = false
|
||||||
|
@Published var isShowPurchaseDialog = false
|
||||||
@Published var page = 0
|
@Published var page = 0
|
||||||
@Published var size = 20
|
@Published var size = 20
|
||||||
@Published var hasNext = false
|
@Published var hasNext = false
|
||||||
@@ -182,6 +183,49 @@ final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func presentPurchaseDialog() {
|
||||||
|
guard let detail, detail.price > 0, detail.existOrdered == false, isOwnPost == false else { return }
|
||||||
|
isShowPurchaseDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseCommunityPost() {
|
||||||
|
guard postId > 0, isLoading == false else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
communityRepository.purchaseCommunityPost(postId: postId)
|
||||||
|
.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.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||||
|
if decoded.success {
|
||||||
|
self.onCommunityRefresh?()
|
||||||
|
self.fetchDetail(postId: self.postId)
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
func sendComment() {
|
func sendComment() {
|
||||||
guard isCommentSendEnabled else { return }
|
guard isCommentSendEnabled else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -321,13 +321,16 @@ struct CreatorChannelView: View {
|
|||||||
} else if let response = viewModel.response {
|
} else if let response = viewModel.response {
|
||||||
CreatorChannelHomeView(
|
CreatorChannelHomeView(
|
||||||
response: response,
|
response: response,
|
||||||
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
onSelectTab: selectTab,
|
onSelectTab: selectTab,
|
||||||
onTapLive: showLiveDetail,
|
onTapLive: showLiveDetail,
|
||||||
onTapContent: showContentDetail,
|
onTapContent: showContentDetail,
|
||||||
onTapDonate: showDonationDialog,
|
onTapDonate: showDonationDialog,
|
||||||
onTapSchedule: handleScheduleTap,
|
onTapSchedule: handleScheduleTap,
|
||||||
onTapSeries: showSeriesDetail,
|
onTapSeries: showSeriesDetail,
|
||||||
onTapCommunityLike: viewModel.likeCommunityPost
|
onTapCommunityLike: viewModel.likeCommunityPost,
|
||||||
|
onTapCommunityMore: communityViewModel.openReportMenu,
|
||||||
|
onTapCommunityDetail: showCommunityPostDetail
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
@@ -474,17 +477,22 @@ struct CreatorChannelView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showCommunityPostDetail(_ post: CreatorChannelCommunityPostItem) {
|
private func showCommunityPostDetail(_ postId: Int) {
|
||||||
AppState.shared.setAppStep(
|
AppState.shared.setAppStep(
|
||||||
step: .creatorChannelCommunityPostDetail(
|
step: .creatorChannelCommunityPostDetail(
|
||||||
postId: post.postId,
|
postId: postId,
|
||||||
onCommunityRefresh: {
|
onCommunityRefresh: {
|
||||||
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
refreshCommunityFeeds()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshCommunityFeeds() {
|
||||||
|
viewModel.fetchHome(creatorId: creatorId)
|
||||||
|
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
|
||||||
private func showAudioContentUpload() {
|
private func showAudioContentUpload() {
|
||||||
isCreatorActionMenuPresented = false
|
isCreatorActionMenuPresented = false
|
||||||
AppState.shared.setAppStep(
|
AppState.shared.setAppStep(
|
||||||
@@ -595,7 +603,7 @@ struct CreatorChannelView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func communityOverlay(proxy: GeometryProxy) -> some View {
|
private func communityOverlay(proxy: GeometryProxy) -> some View {
|
||||||
if viewModel.selectedTab == .community {
|
if viewModel.selectedTab == .home || viewModel.selectedTab == .community {
|
||||||
if communityViewModel.isShowReportMenu {
|
if communityViewModel.isShowReportMenu {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
CreatorCommunityMenuView(
|
CreatorCommunityMenuView(
|
||||||
@@ -603,7 +611,7 @@ struct CreatorChannelView: View {
|
|||||||
isShowCreatorMenu: isOwnCreatorChannel,
|
isShowCreatorMenu: isOwnCreatorChannel,
|
||||||
isFixed: communityViewModel.selectedPostIsPinned,
|
isFixed: communityViewModel.selectedPostIsPinned,
|
||||||
fixedAction: {
|
fixedAction: {
|
||||||
communityViewModel.updateCommunityPostFixed(creatorId: creatorId)
|
communityViewModel.updateCommunityPostFixed(onSuccess: refreshCommunityFeeds)
|
||||||
},
|
},
|
||||||
modifyAction: showCommunityModify,
|
modifyAction: showCommunityModify,
|
||||||
deleteAction: {
|
deleteAction: {
|
||||||
@@ -638,7 +646,7 @@ struct CreatorChannelView: View {
|
|||||||
button1: SodaV2ActionModalButton(
|
button1: SodaV2ActionModalButton(
|
||||||
label: I18n.Common.delete,
|
label: I18n.Common.delete,
|
||||||
action: {
|
action: {
|
||||||
communityViewModel.deleteCommunityPost(creatorId: creatorId)
|
communityViewModel.deleteCommunityPost(onSuccess: refreshCommunityFeeds)
|
||||||
communityViewModel.isShowDeleteConfirm = false
|
communityViewModel.isShowDeleteConfirm = false
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -650,16 +658,6 @@ struct CreatorChannelView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if communityViewModel.isShowPostPurchaseView {
|
|
||||||
CommunityPostPurchaseDialog(
|
|
||||||
isShowing: $communityViewModel.isShowPostPurchaseView,
|
|
||||||
can: communityViewModel.selectedPostPrice,
|
|
||||||
confirmAction: {
|
|
||||||
communityViewModel.purchaseCommunityPost(creatorId: creatorId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if communityViewModel.isLoading {
|
if communityViewModel.isLoading {
|
||||||
LoadingView()
|
LoadingView()
|
||||||
}
|
}
|
||||||
@@ -674,7 +672,7 @@ struct CreatorChannelView: View {
|
|||||||
step: .creatorCommunityModify(
|
step: .creatorCommunityModify(
|
||||||
postId: postId,
|
postId: postId,
|
||||||
onSuccess: {
|
onSuccess: {
|
||||||
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
refreshCommunityFeeds()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,25 +2,28 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CreatorChannelCommunitySection: View {
|
struct CreatorChannelCommunitySection: View {
|
||||||
let communities: [CreatorChannelCommunityPostResponse]
|
let communities: [CreatorChannelCommunityPostResponse]
|
||||||
|
let isOwnCreatorChannel: Bool
|
||||||
let onSelectTab: (CreatorChannelTab) -> Void
|
let onSelectTab: (CreatorChannelTab) -> Void
|
||||||
let onTapLike: (Int) -> Void
|
let onTapLike: (Int) -> Void
|
||||||
let onTapComment: (Int) -> Void
|
let onTapMore: (CreatorChannelCommunityPostItem) -> Void
|
||||||
let onTapPurchase: (CreatorChannelCommunityPostResponse) -> Void
|
let onTapDetail: (Int) -> Void
|
||||||
|
|
||||||
private let homeVisibleCount = 3
|
private let homeVisibleCount = 3
|
||||||
|
|
||||||
init(
|
init(
|
||||||
communities: [CreatorChannelCommunityPostResponse],
|
communities: [CreatorChannelCommunityPostResponse],
|
||||||
|
isOwnCreatorChannel: Bool,
|
||||||
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
||||||
onTapLike: @escaping (Int) -> Void = { _ in },
|
onTapLike: @escaping (Int) -> Void = { _ in },
|
||||||
onTapComment: @escaping (Int) -> Void = { _ in },
|
onTapMore: @escaping (CreatorChannelCommunityPostItem) -> Void = { _ in },
|
||||||
onTapPurchase: @escaping (CreatorChannelCommunityPostResponse) -> Void = { _ in }
|
onTapDetail: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.communities = communities
|
self.communities = communities
|
||||||
|
self.isOwnCreatorChannel = isOwnCreatorChannel
|
||||||
self.onSelectTab = onSelectTab
|
self.onSelectTab = onSelectTab
|
||||||
self.onTapLike = onTapLike
|
self.onTapLike = onTapLike
|
||||||
self.onTapComment = onTapComment
|
self.onTapMore = onTapMore
|
||||||
self.onTapPurchase = onTapPurchase
|
self.onTapDetail = onTapDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -30,27 +33,21 @@ struct CreatorChannelCommunitySection: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
ForEach(visibleCommunities) { community in
|
ForEach(visibleCommunities) { community in
|
||||||
CommunityPostCard(
|
let post = community.communityPostItem
|
||||||
postId: community.postId,
|
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
|
||||||
creatorNickname: community.creatorNickname,
|
|
||||||
creatorProfileImageUrl: community.creatorProfileUrl,
|
CreatorChannelCommunityListItem(
|
||||||
content: community.content,
|
post: post,
|
||||||
imageUrl: community.imageUrl,
|
isOwnPost: isOwnPost,
|
||||||
audioUrl: community.audioUrl,
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
price: community.price,
|
|
||||||
existOrdered: community.existOrdered,
|
|
||||||
createdAt: community.relativeTimeText(),
|
|
||||||
isLike: community.isLiked,
|
|
||||||
likeCount: community.likeCount,
|
|
||||||
commentCount: community.commentCount,
|
|
||||||
onTapLike: {
|
onTapLike: {
|
||||||
onTapLike(community.postId)
|
onTapLike(post.postId)
|
||||||
},
|
},
|
||||||
onTapComment: {
|
onTapMore: {
|
||||||
onTapComment(community.postId)
|
onTapMore(post)
|
||||||
},
|
},
|
||||||
onTapPurchase: {
|
onTapDetail: {
|
||||||
onTapPurchase(community)
|
onTapDetail(post.postId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,8 +80,24 @@ struct CreatorChannelCommunitySection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension CreatorChannelCommunityPostResponse {
|
private extension CreatorChannelCommunityPostResponse {
|
||||||
func relativeTimeText(now: Date = Date()) -> String {
|
var communityPostItem: CreatorChannelCommunityPostItem {
|
||||||
DateParser.relativeTimeText(fromUTC: dateUtc, fallback: dateUtc, now: now)
|
CreatorChannelCommunityPostItem(
|
||||||
|
postId: postId,
|
||||||
|
creatorId: creatorId,
|
||||||
|
creatorNickname: creatorNickname,
|
||||||
|
creatorProfileUrl: creatorProfileUrl,
|
||||||
|
createdAtUtc: dateUtc,
|
||||||
|
content: content,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
audioUrl: audioUrl,
|
||||||
|
price: price,
|
||||||
|
isCommentAvailable: isCommentAvailable,
|
||||||
|
likeCount: likeCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
isPinned: isPinned,
|
||||||
|
existOrdered: existOrdered,
|
||||||
|
isLiked: isLiked
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +120,15 @@ struct CreatorChannelCommunitySection_Previews: PreviewProvider {
|
|||||||
existOrdered: false,
|
existOrdered: false,
|
||||||
likeCount: 5,
|
likeCount: 5,
|
||||||
commentCount: 6,
|
commentCount: 6,
|
||||||
isLiked: false
|
isLiked: false,
|
||||||
|
isCommentAvailable: index != 3,
|
||||||
|
isPinned: index == 1
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSelectTab: { _ in }
|
isOwnCreatorChannel: true,
|
||||||
|
onSelectTab: { _ in },
|
||||||
|
onTapMore: { _ in },
|
||||||
|
onTapDetail: { _ in }
|
||||||
)
|
)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
|||||||
@@ -264,7 +264,9 @@ struct CreatorChannelNoticeSection_Previews: PreviewProvider {
|
|||||||
existOrdered: true,
|
existOrdered: true,
|
||||||
likeCount: 5,
|
likeCount: 5,
|
||||||
commentCount: 2,
|
commentCount: 2,
|
||||||
isLiked: false
|
isLiked: false,
|
||||||
|
isCommentAvailable: true,
|
||||||
|
isPinned: true
|
||||||
),
|
),
|
||||||
CreatorChannelCommunityPostResponse(
|
CreatorChannelCommunityPostResponse(
|
||||||
postId: 2,
|
postId: 2,
|
||||||
@@ -279,7 +281,9 @@ struct CreatorChannelNoticeSection_Previews: PreviewProvider {
|
|||||||
existOrdered: false,
|
existOrdered: false,
|
||||||
likeCount: 3,
|
likeCount: 3,
|
||||||
commentCount: 1,
|
commentCount: 1,
|
||||||
isLiked: false
|
isLiked: false,
|
||||||
|
isCommentAvailable: true,
|
||||||
|
isPinned: false
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CreatorChannelHomeView: View {
|
struct CreatorChannelHomeView: View {
|
||||||
let response: CreatorChannelHomeResponse
|
let response: CreatorChannelHomeResponse
|
||||||
|
let isOwnCreatorChannel: Bool
|
||||||
let onSelectTab: (CreatorChannelTab) -> Void
|
let onSelectTab: (CreatorChannelTab) -> Void
|
||||||
let onTapLive: (Int) -> Void
|
let onTapLive: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
@@ -9,18 +10,24 @@ struct CreatorChannelHomeView: View {
|
|||||||
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
||||||
let onTapSeries: (Int) -> Void
|
let onTapSeries: (Int) -> Void
|
||||||
let onTapCommunityLike: (Int) -> Void
|
let onTapCommunityLike: (Int) -> Void
|
||||||
|
let onTapCommunityMore: (CreatorChannelCommunityPostItem) -> Void
|
||||||
|
let onTapCommunityDetail: (Int) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
response: CreatorChannelHomeResponse,
|
response: CreatorChannelHomeResponse,
|
||||||
|
isOwnCreatorChannel: Bool,
|
||||||
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
||||||
onTapLive: @escaping (Int) -> Void = { _ in },
|
onTapLive: @escaping (Int) -> Void = { _ in },
|
||||||
onTapContent: @escaping (Int) -> Void = { _ in },
|
onTapContent: @escaping (Int) -> Void = { _ in },
|
||||||
onTapDonate: @escaping () -> Void = {},
|
onTapDonate: @escaping () -> Void = {},
|
||||||
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
|
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
|
||||||
onTapSeries: @escaping (Int) -> Void = { _ in },
|
onTapSeries: @escaping (Int) -> Void = { _ in },
|
||||||
onTapCommunityLike: @escaping (Int) -> Void = { _ in }
|
onTapCommunityLike: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapCommunityMore: @escaping (CreatorChannelCommunityPostItem) -> Void = { _ in },
|
||||||
|
onTapCommunityDetail: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.response = response
|
self.response = response
|
||||||
|
self.isOwnCreatorChannel = isOwnCreatorChannel
|
||||||
self.onSelectTab = onSelectTab
|
self.onSelectTab = onSelectTab
|
||||||
self.onTapLive = onTapLive
|
self.onTapLive = onTapLive
|
||||||
self.onTapContent = onTapContent
|
self.onTapContent = onTapContent
|
||||||
@@ -28,6 +35,8 @@ struct CreatorChannelHomeView: View {
|
|||||||
self.onTapSchedule = onTapSchedule
|
self.onTapSchedule = onTapSchedule
|
||||||
self.onTapSeries = onTapSeries
|
self.onTapSeries = onTapSeries
|
||||||
self.onTapCommunityLike = onTapCommunityLike
|
self.onTapCommunityLike = onTapCommunityLike
|
||||||
|
self.onTapCommunityMore = onTapCommunityMore
|
||||||
|
self.onTapCommunityDetail = onTapCommunityDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -69,8 +78,11 @@ struct CreatorChannelHomeView: View {
|
|||||||
|
|
||||||
CreatorChannelCommunitySection(
|
CreatorChannelCommunitySection(
|
||||||
communities: response.communities,
|
communities: response.communities,
|
||||||
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
onSelectTab: onSelectTab,
|
onSelectTab: onSelectTab,
|
||||||
onTapLike: onTapCommunityLike
|
onTapLike: onTapCommunityLike,
|
||||||
|
onTapMore: onTapCommunityMore,
|
||||||
|
onTapDetail: onTapCommunityDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
CreatorChannelFanTalkSection(
|
CreatorChannelFanTalkSection(
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ struct CreatorChannelCommunityPostResponse: Decodable, Identifiable {
|
|||||||
let likeCount: Int
|
let likeCount: Int
|
||||||
let commentCount: Int
|
let commentCount: Int
|
||||||
let isLiked: Bool
|
let isLiked: Bool
|
||||||
|
let isCommentAvailable: Bool
|
||||||
|
let isPinned: Bool
|
||||||
|
|
||||||
var id: Int { postId }
|
var id: Int { postId }
|
||||||
}
|
}
|
||||||
|
|||||||
784
docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/plan-task.md
Normal file
784
docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/plan-task.md
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
# 크리에이터 채널 커뮤니티 아이템 통일 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. 프로젝트 보수적 실행 모드에 따라 multi-agent는 사용하지 않는다.
|
||||||
|
|
||||||
|
**Goal:** 홈과 커뮤니티 탭의 커뮤니티 아이템 외형·상세 진입·더보기를 통일하고 구매는 상세에서만 처리하며, 표시할 reaction이 없을 때 빈 공간을 제거한다.
|
||||||
|
|
||||||
|
**Architecture:** 홈 API 게시글을 private 로컬 변환으로 `CreatorChannelCommunityPostItem`에 연결해 기존 `CreatorChannelCommunityListItem`을 재사용한다. 목록 구매 상태·dialog는 상세 ViewModel·View로 옮기고 기존 구매 API를 재사용하며, 변경 성공 후 홈·커뮤니티 목록을 동기화한다. 목록·홈 공용 아이템과 상세는 유료 잠금 상태에서 reaction bar 전체를 조건부로 제외한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Swift, SwiftUI, Combine, Moya, Kingfisher, SDWebImageSwiftUI, Xcode workspace `SodaLive.xcworkspace`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**`에서 해결하고 신규 dependency를 추가하지 않는다.
|
||||||
|
- 기존 `CommunityPostCard`와 메인 홈 팔로잉·추천 사용처는 수정하지 않는다.
|
||||||
|
- 백엔드는 홈 API의 `notices`·`communities` 아이템에 non-optional `isCommentAvailable: Bool`·`isPinned: Bool`을 제공한다.
|
||||||
|
- 홈 API의 `dateUtc`는 유지하고 iOS에서 값 변환 없이 `createdAtUtc`로 연결한다.
|
||||||
|
- 목록의 리스트형·썸네일형 구매 호출은 제거하고 기존 구매 dialog·API 호출을 상세로 옮긴다.
|
||||||
|
- 유료 미구매 영역은 홈·리스트형·썸네일형·상세 모두 `Color.gray800`을 사용한다.
|
||||||
|
- 하트·댓글을 모두 숨기는 유료 미구매 상태에서는 reaction bar의 고정 높이와 부모 spacing을 남기지 않는다.
|
||||||
|
- 현재 실행 가능한 단일 test bundle이 없으므로 신규 test 타겟을 만들지 않고 정적 contract check·Preview·Debug build·시뮬레이터로 검증한다.
|
||||||
|
- 사용자가 직접 요청하지 않았으므로 `git commit`은 실행하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | reaction bar 회귀 수정·빌드 완료·외부 통합 검증 대기 |
|
||||||
|
| 작성일 | 2026-08-10 |
|
||||||
|
| 요구사항 기준 | `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/prd.md` |
|
||||||
|
| API 기준 | `GET /api/v2/creator-channels/{creatorId}/home` 필드 확장·기존 커뮤니티 목록/상세 API |
|
||||||
|
| 현재 Phase | Phase 3 Gate |
|
||||||
|
| 현재 활성 Goal | `P3-T1`·`P3-GATE`—`EXT-001`·부팅된 Simulator 대기 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
홈 요약·커뮤니티 리스트·썸네일에서 게시글 터치 결과와 잠금 스타일을 통일하고, 더보기 권한 메뉴와 상세 구매 흐름을 정확하게 유지한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `3/3` | `P1-GATE` 완료 | 없음 |
|
||||||
|
| 2 | 완료 | `3/3` | `P2-GATE` 완료 | 없음 |
|
||||||
|
| 3 | 진행 | `0/1` | `P3-T1`, `P3-GATE` | `EXT-001` 반영·Simulator 부팅 |
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 홈 커뮤니티 응답 필드·Preview fixture 반영
|
||||||
|
- 리스트형·썸네일형 목록 구매 제거와 상세 진입 통일
|
||||||
|
- 홈 섹션의 `CreatorChannelCommunityListItem` 재사용
|
||||||
|
- 홈의 고정·수정·삭제·신고 메뉴 연결과 성공 후 목록 동기화
|
||||||
|
- 홈·커뮤니티 리스트와 상세에서 표시할 reaction이 없을 때 빈 공간 제거
|
||||||
|
- 정적 검사·Preview·Debug build·시뮬레이터 검증·문서 누적
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- `CommunityPostCard`와 메인 홈 팔로잉·추천 섹션 변경
|
||||||
|
- 구매 endpoint와 `CommunityPostPurchaseDialog` 내부 UI 변경
|
||||||
|
- 커뮤니티 작성·상세·댓글의 범위 밖 리팩터링
|
||||||
|
- 신규 공용 모델·protocol·factory·dependency·test target 추가
|
||||||
|
- 백엔드 구현과 배포
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- 홈 응답·탭 응답 모델은 통합하지 않고 `CreatorChannelCommunitySection` private 변환으로 연결한다.
|
||||||
|
- 목록 구매 제거로 새로 미사용된 상태·메서드·dialog 분기만 제거한다.
|
||||||
|
- 홈·커뮤니티 변경 성공 후 갱신은 `CreatorChannelView`의 한 helper에서 두 ViewModel 재조회를 호출한다.
|
||||||
|
- 사용자 노출 오류는 기존 `errorMessage`·`isShowPopup`·toast 흐름을 유지한다.
|
||||||
|
- 홈 응답에 추가 필드가 없으면 decoding 실패를 기존 홈 API 실패로 처리하고 fallback 값을 만들지 않는다.
|
||||||
|
|
||||||
|
## Task 검증 규칙
|
||||||
|
|
||||||
|
- 현재 프로젝트에 실행 가능한 unit test bundle이 없어 SwiftUI View·Decodable 변경에 대한 신규 unit test를 추가하지 않는다.
|
||||||
|
- 각 구현 Task는 변경 전 구조를 재현하는 실행 가능한 `rg` contract check를 **RED**로 실행한다.
|
||||||
|
- 최소 구현 후 같은 contract check의 기대 결과가 반전되는지 확인하고, 영향 범위 `xcodebuild`로 **GREEN**을 판정한다.
|
||||||
|
- Preview·시뮬레이터 검증은 시각·gesture 동작이 unit test target 없이 자동화되지 않는 부분의 대체 완료 증거로 사용한다.
|
||||||
|
|
||||||
|
## Phase 1: 홈 계약과 목록 상호작용
|
||||||
|
|
||||||
|
**Phase 결과:** 홈 응답이 필요한 고정·댓글 상태를 디코딩하고, 커뮤니티 리스트형·썸네일형은 상세로 이동하며 구매 dialog·API는 상세에서 동작한다.
|
||||||
|
|
||||||
|
**선행조건:** PRD `CCCI-004`, `CCCI-005`, `CCCI-007`, `CCCI-008`과 `EXT-001` 계약 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-T3`, `P1-GATE` 완료.
|
||||||
|
|
||||||
|
### Task 1.1 홈 커뮤니티 응답 계약 반영
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 홈 커뮤니티·공지 아이템이 `isCommentAvailable`·`isPinned`을 non-optional Bool로 디코딩한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `EXT-001` 필드명·타입 확정.
|
||||||
|
- **완료 증거:** 모델·Preview fixture 반영, RED/GREEN contract check, `SodaLive-dev` Debug build.
|
||||||
|
- **범위 밖:** 백엔드 구현, 모델 통합, fallback 기본값.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift`
|
||||||
|
- Test: 신규 test 파일 없음—기존 Preview fixture·contract check·Debug build 사용
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 홈 API `communities`·`notices` 아이템의 `isCommentAvailable: Bool`·`isPinned: Bool`.
|
||||||
|
- Produces: `CreatorChannelCommunityPostResponse.isCommentAvailable: Bool`, `CreatorChannelCommunityPostResponse.isPinned: Bool`.
|
||||||
|
|
||||||
|
- [x] **RED:** 홈 응답 모델에 두 필드가 없음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "let (isCommentAvailable|isPinned): Bool" SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: match 0건, exit code 1.
|
||||||
|
|
||||||
|
- [x] **RED 확인:** 현재 Preview fixture가 두 필드 인자를 전달하지 않음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "isCommentAvailable:|isPinned:" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: match 0건, exit code 1.
|
||||||
|
|
||||||
|
- [x] **GREEN:** `CreatorChannelCommunityPostResponse` 마지막에 두 필드를 추가하고 세 개 Preview fixture에 명시적 샘플 값을 전달한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let commentCount: Int
|
||||||
|
let isLiked: Bool
|
||||||
|
let isCommentAvailable: Bool
|
||||||
|
let isPinned: Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview 규칙:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
isCommentAvailable: index != 3,
|
||||||
|
isPinned: index == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreatorChannelNoticeSection` fixture는 각 아이템에 `isCommentAvailable: true`·`isPinned: true/false`를 명시한다.
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 두 필드·세 fixture가 있고 Debug build가 성공함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "let (isCommentAvailable|isPinned): Bool" SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift
|
||||||
|
rg -n "isCommentAvailable:|isPinned:" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelNoticeSection.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모델 2건·fixture 6건 이상 match, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 추가 변환 타입·optional fallback·주석을 만들지 않고 `git diff --check` 결과를 누적한다.
|
||||||
|
|
||||||
|
### Task 1.2 목록 구매 제거와 잠금 배경 통일
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 커뮤니티 리스트형·썸네일형의 유료 미구매 영역이 `Color.gray800`으로 보이고 터치 시 상세로 이동한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료.
|
||||||
|
- **완료 증거:** 목록 구매 심볼 0건, `Color.gray400` 잠금 배경 0건, 썸네일 유료 Preview, Debug build.
|
||||||
|
- **범위 밖:** 상세 구매 연결은 `P1-T3`에서 처리하고, 구매 endpoint·dialog 내부 UI는 변경하지 않는다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||||
|
- Test: `CreatorChannelCommunityGridItem_Previews` in the grid item file·contract check·Debug build
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 `onTapDetail`, `CreatorChannelCommunityPostDetailView` 구매 흐름.
|
||||||
|
- Produces: `CreatorChannelCommunityListItem` init without `onTapPurchase`, `CreatorChannelCommunityGridItem` init without `onTapPurchase`.
|
||||||
|
|
||||||
|
- [x] **RED:** 목록 구매 상태·callback·dialog가 남아 있음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "onTapPurchase|openPurchaseDialog|isShowPostPurchaseView|selectedPostPrice|purchaseCommunityPost" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 기존 호출처·상태·메서드 match, exit code 0.
|
||||||
|
|
||||||
|
- [x] **RED 확인:** 리스트형·썸네일형 잠금 배경이 `Color.gray400`인 것을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "\.fill\(Color\.gray400\)|^[[:space:]]*Color\.gray400[[:space:]]*$" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 잠금 배경 2건 match, exit code 0.
|
||||||
|
|
||||||
|
- [x] **GREEN:** 두 아이템의 `onTapPurchase`를 제거하고 잠금 영역을 상세 진입 gesture에 포함한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private var paidLockedImage: some View {
|
||||||
|
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||||
|
.fill(Color.gray800)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 236)
|
||||||
|
.overlay(paidPriceOverlay)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Grid `ZStack`은 잠금/접근 가능 분기 밖에 하나의 gesture를 둔다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onTapDetail)
|
||||||
|
```
|
||||||
|
|
||||||
|
썸네일 유료 잠금 상태를 고정된 fixture로 확인할 Preview를 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct CreatorChannelCommunityGridItem_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
CreatorChannelCommunityGridItem(
|
||||||
|
post: CreatorChannelCommunityPostItem(
|
||||||
|
postId: 1,
|
||||||
|
creatorId: 10,
|
||||||
|
creatorNickname: "크리에이터 이름",
|
||||||
|
creatorProfileUrl: "https://picsum.photos/600",
|
||||||
|
createdAtUtc: "2026-08-10T00:00:00Z",
|
||||||
|
content: "유료 커뮤니티 게시글",
|
||||||
|
imageUrl: nil,
|
||||||
|
audioUrl: nil,
|
||||||
|
price: 30,
|
||||||
|
isCommentAvailable: true,
|
||||||
|
likeCount: 5,
|
||||||
|
commentCount: 2,
|
||||||
|
isPinned: true,
|
||||||
|
existOrdered: false,
|
||||||
|
isLiked: false
|
||||||
|
),
|
||||||
|
isOwnPost: false,
|
||||||
|
onTapDetail: {}
|
||||||
|
)
|
||||||
|
.frame(width: 134, height: 134)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreatorChannelCommunityViewModel`에서 `isShowPostPurchaseView`, `selectedPostPrice`, `openPurchaseDialog`, `purchaseCommunityPost`를 제거하고 `CreatorChannelView.communityOverlay`의 목록 구매 dialog 분기를 제거한다.
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 목록 구매 심볼·`Color.gray400`이 사라지고 두 아이템에 `Color.gray800`이 있음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "onTapPurchase|openPurchaseDialog|isShowPostPurchaseView|selectedPostPrice|purchaseCommunityPost" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
rg -n "\.fill\(Color\.gray400\)|^[[:space:]]*Color\.gray400[[:space:]]*$" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift
|
||||||
|
rg -n "Color\.gray800" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 `rg`와 잠금 배경 `rg` 각각 match 0건·exit code 1, 세 번째 `rg` 두 파일 match, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 목록 구매 제거로 새로 미사용된 import·state만 정리하고 상세 구매 연결은 `P1-T3`에 남긴다.
|
||||||
|
|
||||||
|
### Task 1.3 기존 구매 흐름을 상세로 이동
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 상세의 유료 미구매 잠금 영역에서 기존 구매 다이얼로그·API가 동작하고 성공 후 상세·홈·커뮤니티 목록을 갱신한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T2` 완료.
|
||||||
|
- **완료 증거:** `showPurchaseError` 0건, 상세 구매 dialog·API 연결, Debug build.
|
||||||
|
- **범위 밖:** 구매 endpoint, `CommunityPostPurchaseDialog` 내부 UI, 결제 정책 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- Test: contract check·Debug build·시뮬레이터 상세 구매 확인
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CreatorCommunityRepository.purchaseCommunityPost(postId:)`, `CommunityPostPurchaseDialog`, `onCommunityRefresh`.
|
||||||
|
- Produces: `isShowPurchaseDialog`, `presentPurchaseDialog()`, `purchaseCommunityPost()`.
|
||||||
|
|
||||||
|
- [x] **RED:** 상세 잠금 영역이 구매 대신 공통 오류만 표시하고 상세 구매 상태·메서드가 없음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "showPurchaseError|onTapPurchase: showPurchaseError" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift
|
||||||
|
rg -n "isShowPurchaseDialog|CommunityPostPurchaseDialog|func (presentPurchaseDialog|purchaseCommunityPost)" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령 match, 둘째 명령 match 0건·exit code 1.
|
||||||
|
|
||||||
|
- [x] **GREEN:** 상세 View에 기존 dialog를 표시하고 상세 ViewModel에 기존 구매 API 호출을 연결한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Published var isShowPurchaseDialog = false
|
||||||
|
|
||||||
|
func presentPurchaseDialog() {
|
||||||
|
guard let detail, detail.price > 0, detail.existOrdered == false, isOwnPost == false else { return }
|
||||||
|
isShowPurchaseDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaseCommunityPost()
|
||||||
|
```
|
||||||
|
|
||||||
|
구매 성공 분기는 `fetchDetail(postId:)`과 `onCommunityRefresh?()`를 호출한다. 상세 View는 `onTapPurchase: viewModel.presentPurchaseDialog`와 아래 기존 dialog를 연결한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
CommunityPostPurchaseDialog(
|
||||||
|
isShowing: $viewModel.isShowPurchaseDialog,
|
||||||
|
can: detail.price,
|
||||||
|
confirmAction: viewModel.purchaseCommunityPost
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 오류 placeholder가 제거되고 상세 구매 연결·Debug build가 통과함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "showPurchaseError" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift
|
||||||
|
rg -n "isShowPurchaseDialog|CommunityPostPurchaseDialog|func (presentPurchaseDialog|purchaseCommunityPost)|purchaseCommunityPost\(postId:" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 `rg` match 0건·exit code 1, 둘째 `rg` 필수 연결 match, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 목록 ViewModel에 구매 상태를 되돌리지 않고 상세의 기존 error/toast·refresh 흐름을 재사용한다.
|
||||||
|
|
||||||
|
### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 홈 계약과 목록 상호작용 변경의 컴파일·정적 계약을 판정한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
rg -n "let (isCommentAvailable|isPinned): Bool" SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift
|
||||||
|
rg -n "Color\.gray800" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
rg -n "CommunityPostPurchaseDialog|func purchaseCommunityPost|purchaseCommunityPost\(postId:" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: whitespace 오류 0건, 필드 2건, 세 UI의 `Color.gray800`·상세 구매 연결 match, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
## Phase 2: 홈 아이템 재사용과 메뉴 연결
|
||||||
|
|
||||||
|
**Phase 결과:** 홈이 기존 커뮤니티 리스트 아이템·상세 route·권한 메뉴를 재사용하고 변경 성공 후 홈·커뮤니티 목록을 동기화한다.
|
||||||
|
|
||||||
|
**선행조건:** `P1-GATE` 완료.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-R1`, `P2-GATE` 완료.
|
||||||
|
|
||||||
|
### Task 2.1 홈 섹션에 기존 리스트 아이템·상세·더보기 연결
|
||||||
|
|
||||||
|
**Goal 실행 `P2-T1`:** 홈 섹션이 최대 3개의 `CreatorChannelCommunityListItem`을 표시하고 본체는 상세로, 더보기는 기존 권한 메뉴로 연결한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-GATE` 완료.
|
||||||
|
- **완료 증거:** `CommunityPostCard` 사용 0건, 로컬 변환·`postId` route·홈 overlay 연결, Preview, Debug build.
|
||||||
|
- **범위 밖:** 신규 표시 모델, 메뉴 UI 복제, 메인 홈 `CommunityPostCard` 수정.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||||
|
- Test: `CreatorChannelCommunitySection_Previews`·contract check·Debug build
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CreatorChannelCommunityPostResponse`, `CreatorChannelCommunityPostItem`, `CreatorChannelCommunityViewModel.openReportMenu(post:)`, `AppStep.creatorChannelCommunityPostDetail`.
|
||||||
|
- Produces: `CreatorChannelCommunitySection(isOwnCreatorChannel:onTapMore:onTapDetail:)`, `CreatorChannelHomeView(isOwnCreatorChannel:onTapCommunityMore:onTapCommunityDetail:)`, `showCommunityPostDetail(_ postId: Int)`.
|
||||||
|
|
||||||
|
- [x] **RED:** 홈 섹션이 아직 `CommunityPostCard`를 사용하고 홈 상세·더보기 callback이 없음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "CommunityPostCard\(" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift
|
||||||
|
rg -n "onTapCommunity(More|Detail)|communityPostItem" SodaLive/Sources/V2/CreatorChannel/Home SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령 match 1건·exit code 0, 둘째 명령 match 0건·exit code 1.
|
||||||
|
|
||||||
|
- [x] **RED 확인:** `communityOverlay` 노출이 `.community`에만 한정됨을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "if viewModel\.selectedTab == \.community" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `communityOverlay` 함수 내 match 포함, exit code 0.
|
||||||
|
|
||||||
|
- [x] **GREEN:** 홈 게시글을 커뮤니티 탭 아이템으로 연결하는 private property를 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private extension CreatorChannelCommunityPostResponse {
|
||||||
|
var communityPostItem: CreatorChannelCommunityPostItem {
|
||||||
|
CreatorChannelCommunityPostItem(
|
||||||
|
postId: postId,
|
||||||
|
creatorId: creatorId,
|
||||||
|
creatorNickname: creatorNickname,
|
||||||
|
creatorProfileUrl: creatorProfileUrl,
|
||||||
|
createdAtUtc: dateUtc,
|
||||||
|
content: content,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
audioUrl: audioUrl,
|
||||||
|
price: price,
|
||||||
|
isCommentAvailable: isCommentAvailable,
|
||||||
|
likeCount: likeCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
isPinned: isPinned,
|
||||||
|
existOrdered: existOrdered,
|
||||||
|
isLiked: isLiked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreatorChannelCommunitySection`은 `isOwnCreatorChannel: Bool`, `onTapMore: (CreatorChannelCommunityPostItem) -> Void`, `onTapDetail: (Int) -> Void`를 받고 `CreatorChannelCommunityListItem`에 연결한다. `CreatorChannelHomeView`는 동일 상태·callback을 전달한다.
|
||||||
|
|
||||||
|
홈 섹션 Preview도 변경된 입력 계약을 명시한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
CreatorChannelCommunitySection(
|
||||||
|
communities: previewCommunities,
|
||||||
|
isOwnCreatorChannel: true,
|
||||||
|
onSelectTab: { _ in },
|
||||||
|
onTapMore: { _ in },
|
||||||
|
onTapDetail: { _ in }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** 홈·커뮤니티 탭이 `postId` 기반 상세 helper를 공통 사용하고 홈에서도 기존 overlay를 보여준다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private func showCommunityPostDetail(_ postId: Int) {
|
||||||
|
AppState.shared.setAppStep(
|
||||||
|
step: .creatorChannelCommunityPostDetail(
|
||||||
|
postId: postId,
|
||||||
|
onCommunityRefresh: {
|
||||||
|
viewModel.fetchHome(creatorId: creatorId)
|
||||||
|
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreatorChannelCommunityTabView.onTapDetail`도 `(Int) -> Void`로 줄이고 `post.postId`를 전달한다. `communityOverlay` 조건은 `.home || .community`로 확장한다.
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 기존 카드·탭 전용 상세 모델 callback이 제거되고 새 연결과 Debug build가 통과함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "CommunityPostCard\(" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift
|
||||||
|
rg -n "communityPostItem|CreatorChannelCommunityListItem\(|onTapCommunityMore|onTapCommunityDetail|showCommunityPostDetail\(_ postId: Int\)" SodaLive/Sources/V2/CreatorChannel/Home SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령 match 0건·exit code 1, 둘째 명령 필수 연결 match, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** `CreatorChannelCommunitySection.relativeTimeText` 같은 카드 제거로 미사용된 헬퍼만 제거하고, `CommunityPostCard`의 다른 사용처는 건드리지 않는다.
|
||||||
|
|
||||||
|
### Task 2.2 관리 동작 성공 후 홈·커뮤니티 동기화
|
||||||
|
|
||||||
|
**Goal 실행 `P2-T2`:** 홈 더보기에서 고정·수정·삭제한 결과가 홈과 커뮤니티 탭 목록에 모두 반영된다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P2-T1` 완료.
|
||||||
|
- **완료 증거:** 한 개의 `refreshCommunityFeeds()` helper, 변경 성공 callback, Debug build.
|
||||||
|
- **범위 밖:** 좋아요 optimistic state, 신고 성공 후 목록 재조회, 메뉴 UI 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||||
|
- Test: contract check·Debug build·시뮬레이터 관리 액션 확인
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `deleteCommunityPost`, `updateCommunityPostFixed`, `showCommunityModify`, 두 ViewModel의 재조회 메서드.
|
||||||
|
- Produces: `deleteCommunityPost(onSuccess:)`, `updateCommunityPostFixed(onSuccess:)`, `refreshCommunityFeeds()`.
|
||||||
|
|
||||||
|
- [x] **RED:** 삭제·고정·수정 성공 후 커뮤니티 탭만 재조회하는 현재 구조를 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "fetchFirstPage\(creatorId: creatorId\)|fetchHome\(creatorId: creatorId\)|deleteCommunityPost\(creatorId:|updateCommunityPostFixed\(creatorId:" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ViewModel 내 재조회와 `creatorId` 기반 mutation, 수정 callback의 커뮤니티 탭 재조회 match.
|
||||||
|
|
||||||
|
- [x] **RED 확인:** `refreshCommunityFeeds` helper가 없음을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "refreshCommunityFeeds" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: match 0건, exit code 1.
|
||||||
|
|
||||||
|
- [x] **GREEN:** ViewModel mutation은 성공 시 주입된 callback을 호출하고 재조회 소유권을 `CreatorChannelView`로 옮긴다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func deleteCommunityPost(onSuccess: @escaping () -> Void)
|
||||||
|
func updateCommunityPostFixed(onSuccess: @escaping () -> Void)
|
||||||
|
|
||||||
|
private func refreshCommunityFeeds() {
|
||||||
|
viewModel.fetchHome(creatorId: creatorId)
|
||||||
|
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
두 mutation의 기존 성공 분기에서 ViewModel 자체 재조회 대신 callback을 실행한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if decoded.success {
|
||||||
|
self.errorMessage = I18n.Explorer.deleted
|
||||||
|
self.isShowPopup = true
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if decoded.success {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`fixedAction`, 삭제 확인 action, `showCommunityModify` 성공 callback이 `refreshCommunityFeeds` 하나를 사용하도록 연결한다.
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** helper가 두 재조회를 포함하고 mutation의 기존 `creatorId` 인자·ViewModel 내 재조회가 제거됨을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "refreshCommunityFeeds|fetchHome\(creatorId: creatorId\)|fetchFirstPage\(creatorId: creatorId\)" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
rg -n "deleteCommunityPost\(creatorId:|updateCommunityPostFixed\(creatorId:|fetchFirstPage\(creatorId: creatorId\)" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령은 helper·두 재조회·세 action 연결 match, 둘째 명령 match 0건·exit code 1, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 기존 글 작성 성공 callback은 요청 범위가 아니므로 별도 정리하지 않고 이번 변경의 중복만 제거한다.
|
||||||
|
|
||||||
|
### Regression Task 2.3 표시할 reaction이 없을 때 빈 공간 제거
|
||||||
|
|
||||||
|
**Goal 실행 `P2-R1`:** 홈·커뮤니티 리스트와 상세의 유료 미구매 상태에서 하트·댓글과 reaction bar의 고정 높이를 함께 제거한다.
|
||||||
|
|
||||||
|
- **리뷰 근거:** `REV-P3-003` — `reviews/phase3-community-item-unification.md`
|
||||||
|
- **시작 조건:** 사용자 회귀 제보와 코드 근거 확인, PRD `CCCI-009`·`DEC-013` 반영.
|
||||||
|
- **완료 증거:** 두 View의 무조건 `reactionBar` 삽입 0건, `if !isPaidLocked` 조건부 삽입 2건, `git diff --check`, Debug build.
|
||||||
|
- **범위 밖:** reaction icon·count 스타일 변경, 썸네일 레이아웃 변경, 신규 helper·test target 추가.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- Modify: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
||||||
|
- Test: 신규 test 파일 없음—focused contract check·Debug build 사용
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 각 View의 기존 `isPaidLocked`, `reactionBar`.
|
||||||
|
- Produces: 유료 잠금 상태에서 `reactionBar`가 View 계층에 없는 레이아웃.
|
||||||
|
|
||||||
|
- [x] **RED:** 두 View body가 `reactionBar`를 무조건 삽입하고, 내부의 빈 `HStack`이 `.frame(height: 24)`를 유지함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n '^[[:space:]]{12}reactionBar$' SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
rg -n 'frame\(height: 24\)' SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령 match 2건, 둘째 명령 match 2건.
|
||||||
|
|
||||||
|
- [x] **GREEN:** 두 body에서 `reactionBar`를 `if !isPaidLocked`일 때만 삽입하고, 내부의 중복 잠금 조건만 제거한다.
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 무조건 삽입이 사라지고 조건부 삽입·Debug build가 통과함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n '^[[:space:]]{12}reactionBar$' SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
rg -n -U 'if !isPaidLocked \{\n[[:space:]]+reactionBar' SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
git diff --check
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 첫 명령 match 0건·exit code 1, 둘째 명령 match 2건, whitespace 오류 0건, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 신규 상태·helper를 추가하지 않고 기존 `isPaidLocked`만 재사용한다.
|
||||||
|
|
||||||
|
### Phase 2 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P2-GATE`:** 홈 재사용·상세·더보기·목록 동기화 구조를 판정한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
rg -n "communityPostItem|CreatorChannelCommunityListItem\(|onTapCommunityMore|onTapCommunityDetail|refreshCommunityFeeds" SodaLive/Sources/V2/CreatorChannel/Home SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
rg -n "CommunityPostCard\(" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelCommunitySection.swift
|
||||||
|
rg -n -U 'if !isPaidLocked \{\n[[:space:]]+reactionBar' SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: whitespace 오류 0건, 필수 연결 match, 홈 `CommunityPostCard` match 0건·exit code 1, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
## Phase 3: 리뷰·통합 검증·문서 종결
|
||||||
|
|
||||||
|
**Phase 결과:** 정적 계약·Debug build·Preview·시뮬레이터 흐름을 확인하고 PRD·계획·리뷰 기록을 동기화한다.
|
||||||
|
|
||||||
|
**선행조건:** `P2-GATE` 완료. 시뮬레이터 통합 판정은 `EXT-001` 반영 후 실행.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료·검증 기록 누적.
|
||||||
|
|
||||||
|
### Task 3.1 영향 범위 리뷰와 완료 증거 누적
|
||||||
|
|
||||||
|
**Goal 실행 `P3-T1`:** 확정 요구사항 전체의 자동·수동 증거와 잔여 위험을 문서화한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P2-GATE` 완료.
|
||||||
|
- **완료 증거:** 리뷰 리포트, 정적 검사, Debug build, Preview/시뮬레이터 결과, PRD·계획 Progress.
|
||||||
|
- **범위 밖:** 실패와 무관한 리팩터링, test target 추가, 백엔드 수정.
|
||||||
|
- **TDD 예외 사유:** production 동작을 추가하지 않는 리뷰·검증·문서 Task다.
|
||||||
|
- **대체 검증 방법:** 아래 정적 검사·Debug build·Preview·시뮬레이터 대조표를 사용한다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/reviews/phase3-community-item-unification.md`
|
||||||
|
- Modify: `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/prd.md`
|
||||||
|
- Modify: `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/plan-task.md`
|
||||||
|
- Test: 정적 검사·Debug build·Preview·시뮬레이터 대체 검증
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CCCI-001~008`, `EXT-001`, Phase 1·2 산출물.
|
||||||
|
- Produces: Phase 3 리뷰 판정, 최종 Progress, 남은 외부 조건.
|
||||||
|
|
||||||
|
- [x] 정적 검사로 필수 연결과 금지 호출처를 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "let (isCommentAvailable|isPinned): Bool" SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift
|
||||||
|
rg -n "communityPostItem|CreatorChannelCommunityListItem\(|showCommunityPostDetail\(_ postId: Int\)|refreshCommunityFeeds" SodaLive/Sources/V2/CreatorChannel/Home SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
rg -n "onTapPurchase|openPurchaseDialog|isShowPostPurchaseView|selectedPostPrice|purchaseCommunityPost" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||||
|
rg -n "Color\.gray800" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 필드·홈 연결·세 UI의 `Color.gray800` match, 목록 구매 심볼 0건·exit code 1, whitespace 오류 0건.
|
||||||
|
|
||||||
|
- [x] `SodaLive-dev` Debug build를 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit code 0, `** BUILD SUCCEEDED **`.
|
||||||
|
|
||||||
|
- [ ] Preview에서 홈 유료 잠금·고정·댓글 비활성 상태와 썸네일 유료 잠금 상태를 확인한다.
|
||||||
|
- [ ] `EXT-001` 반영 후 시뮬레이터에서 홈·리스트형·썸네일형 본체 터치가 상세로 이동하고 목록 구매 dialog가 나오지 않음을 확인한다.
|
||||||
|
- [ ] 본인 채널의 고정·수정·삭제, 타인 채널의 신고, 상세 구매, 더보기·좋아요 터치 중복 없음을 수동 확인한다.
|
||||||
|
- [x] `docs/sample/sample-review.md`를 읽고 `phase3-community-item-unification.md`에 검토 범위·근거·명령·발견 사항을 기록한다.
|
||||||
|
- [x] 확정 수정 항목이 있으면 코드 수정 전 해당 Phase에 회귀 Task를 추가한다.
|
||||||
|
- [x] PRD 상태·요구사항 추적표와 이 문서의 체크박스·Progress를 실제 결과로 갱신한다.
|
||||||
|
|
||||||
|
### Phase 3 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P3-GATE`:** 확정 요구사항·자동 검증·수동 검증·문서 추적성을 최종 판정한다.
|
||||||
|
|
||||||
|
- [x] `P3-T1` 리뷰에 확정 수정 항목이 없거나 추가 회귀 Task가 완료됐다.
|
||||||
|
- [x] `git diff --check`·정적 contract check·`SodaLive-dev` Debug build가 통과했다.
|
||||||
|
- [ ] Preview 검증이 통과했다.
|
||||||
|
- [ ] `EXT-001` 반영 후 시뮬레이터 통합 검증이 통과했다. 미반영이면 Phase를 완료 표시하지 않고 재개 조건을 Progress에 남긴다.
|
||||||
|
- [x] PRD·`plan-task.md`·review 리포트의 판정과 검증 기록이 일치한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | PRD·`EXT-001` 계약 확정 | 아니요 | 백엔드 필드명·타입 확인 |
|
||||||
|
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 목록 호출처·상세 구매 경계 재확인 |
|
||||||
|
| 3 | `P1-T3` | `P1-T2` 완료 | 아니요 | 기존 상세 잠금·구매 API 경계 재확인 |
|
||||||
|
| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task 수정 |
|
||||||
|
| 5 | `P2-T1` | `P1-GATE` | 아니요 | 홈·탭 모델 매핑 재확인 |
|
||||||
|
| 6 | `P2-T2` | `P2-T1` | 아니요 | mutation 성공 callback 호출처 재확인 |
|
||||||
|
| 7 | `P2-R1` | 사용자 회귀 제보·PRD 반영 | 아니요 | reaction bar 삽입 조건 재확인 |
|
||||||
|
| 8 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task 수정 |
|
||||||
|
| 9 | `P3-T1` | `P2-GATE` | 아니요 | 리뷰 수정 Task 추가 |
|
||||||
|
| 10 | `P3-GATE` | `P3-T1`·`EXT-001` | 아니요 | 백엔드 반영 후 통합 검증 재개 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
P1-T1 → P1-T2 → P1-T3 → P1-GATE → P2-T1 → P2-T2 → P2-R1 → P2-GATE → P3-T1 → P3-GATE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- `CommunityPostCard`와 메인 홈 팔로잉·추천 사용처를 수정하지 않는다.
|
||||||
|
- 구매 endpoint와 `CommunityPostPurchaseDialog` 내부 UI를 변경하지 않는다.
|
||||||
|
- 홈 API의 `dateUtc`를 임의로 rename·parse·timezone 변환하지 않는다.
|
||||||
|
- 백엔드 미반영 필드에 optional·fallback 값을 만들어 통합 실패를 숨기지 않는다.
|
||||||
|
- 신규 protocol·factory·display model·dependency·test target을 추가하지 않는다.
|
||||||
|
- 요청 범위 밖의 기존 dead code·format·naming을 정리하지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- PRD·현재 API 응답·구현이 충돌하면 PRD의 `EXT-001`을 우선하고 추정 구현하지 않는다.
|
||||||
|
- iOS 컴파일과 Preview 작업은 백엔드 반영 전에 진행할 수 있지만, 시뮬레이터 통합 판정은 `EXT-001` 반영 후에만 완료한다.
|
||||||
|
- 구현 범위가 바뀌면 PRD Decision Log·요구사항·이 문서 체크박스를 코드 수정 전에 갱신한다.
|
||||||
|
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 독립 작업도 불가능할 때만 차단으로 판정한다.
|
||||||
|
- 체크박스·검증·Progress가 모두 충족되기 전에 Goal·Phase를 완료 표시하지 않는다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
기존 기록을 삭제하지 않고 실제 실행 결과를 누적한다.
|
||||||
|
|
||||||
|
### 계획 작성 — 2026-08-10
|
||||||
|
|
||||||
|
- 상태: 계획 검토 대기
|
||||||
|
- 무엇을: 인터뷰·설계 승인 결과를 Phase 1~3·Task 6개·Gate 3개로 변환했고, 기준선 확인에서 누락된 상세 구매 이동 Task를 추가했다.
|
||||||
|
- 왜: 코드 수정 전 파일 범위·인터페이스·검증·외부 의존을 확정하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `rg`로 현재 모델·callback·구매 상태·색상·Preview fixture 호출처를 확인했다.
|
||||||
|
- `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`, `agent-execution-policy.md`와 현재 PRD·샘플을 대조했다.
|
||||||
|
- 남은 항목: 사용자 계획 검토, `P1-T1` 시작.
|
||||||
|
- 다음 행동: 계획 승인 후 `superpowers:executing-plans`로 `P1-T1`을 실행한다.
|
||||||
|
|
||||||
|
### Phase 1~2 구현 — 2026-08-10
|
||||||
|
|
||||||
|
- 상태: 코드·정적 계약·Debug build 완료
|
||||||
|
- 무엇을:
|
||||||
|
- 홈 응답에 `isCommentAvailable`·`isPinned`을 추가했다.
|
||||||
|
- 목록 구매를 제거하고 잠금 배경을 `Color.gray800`으로 통일했다.
|
||||||
|
- 기준선에서 발견한 상세 구매 누락을 `P1-T3`로 추가한 뒤 기존 dialog·API를 상세로 이동했다.
|
||||||
|
- 홈을 `CreatorChannelCommunityListItem`으로 교체하고 상세·더보기·두 목록 refresh를 연결했다.
|
||||||
|
- 왜: `CCCI-001~008`의 iOS 코드 범위를 충족하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 각 Task의 RED/GREEN `rg` contract check를 실행했다.
|
||||||
|
- iPhone 17 Pro iOS 26.0 대상 `SodaLive-dev` Debug build를 실행해 exit code 0을 확인했다.
|
||||||
|
- 남은 항목: Preview·Simulator 통합 검증.
|
||||||
|
- 다음 행동: Phase 3 리뷰와 외부 조건 확인.
|
||||||
|
|
||||||
|
### Phase 3 리뷰 — 2026-08-10
|
||||||
|
|
||||||
|
- 상태: 코드 리뷰 완료·통합 검증 대기
|
||||||
|
- 무엇을: 전체 diff를 PRD·계획과 대조하고 `reviews/phase3-community-item-unification.md`를 작성했다.
|
||||||
|
- 왜: 완료 주장을 코드·빌드·수동 증거로 분리하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 정적 계약·`git diff --check`·Debug build는 통과했다.
|
||||||
|
- `xcrun simctl list devices` 결과 사용 가능한 Simulator가 모두 `Shutdown`임을 확인했다.
|
||||||
|
- 남은 항목: `EXT-001` 반영, Preview, 본체 상세 이동·권한별 더보기·목록 dialog 미노출·상세 구매 수동 확인.
|
||||||
|
- 다음 행동: 사용자가 Simulator를 부팅하고 백엔드 필드 반영을 알리면 `P3-T1`·`P3-GATE`를 재개한다.
|
||||||
|
|
||||||
|
### Reaction bar 회귀 수정 — 2026-08-10
|
||||||
|
|
||||||
|
- 상태: `P2-R1` 구현 전
|
||||||
|
- 무엇을: 홈·커뮤니티 리스트와 상세의 하트·댓글이 모두 숨겨질 때 남는 빈 공간을 회귀 Task로 추가했다.
|
||||||
|
- 왜: 두 View가 빈 reaction `HStack`의 고정 24pt 높이를 계속 배치하는 근본 원인을 확인했다.
|
||||||
|
- 어떻게: 공용 리스트 아이템과 상세 content View의 body·`reactionBar`를 추적하고 `REV-P3-003`, `CCCI-009`, `DEC-013`을 연결했다.
|
||||||
|
- 남은 항목: RED 확인, 최소 코드 수정, 정적 검사·Debug build, 문서 결과 누적.
|
||||||
|
- 다음 행동: `P2-R1`을 실행한다.
|
||||||
|
|
||||||
|
### Reaction bar 회귀 수정 완료 — 2026-08-10
|
||||||
|
|
||||||
|
- 상태: `P2-R1`·`P2-GATE` 완료
|
||||||
|
- 무엇을: 공용 리스트 아이템과 상세에서 유료 잠금 상태일 때 `reactionBar` 전체를 View 계층에서 제외했다.
|
||||||
|
- 왜: 하트·댓글 자식만 숨기면 빈 `HStack`의 24pt 높이와 부모 spacing이 남기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- RED: body의 무조건 `reactionBar` 삽입 2건, `.frame(height: 24)` 2건을 확인했다.
|
||||||
|
- GREEN: 무조건 삽입 0건, `if !isPaidLocked` 조건부 삽입 2건을 확인했다.
|
||||||
|
- `git diff --check` 오류 0건, iPhone 17 Pro 대상 `SodaLive-dev` Debug build exit code 0을 확인했다.
|
||||||
|
- 남은 항목: 기존 `EXT-001`·부팅된 Simulator 기반 Preview/통합 검증.
|
||||||
|
- 다음 행동: 외부 조건 충족 후 `P3-GATE`를 재개한다.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-10 | `PLAN-DEC-001` | 확정 | 로컬 변환·기존 컴포넌트·기존 메뉴 상태를 재사용한다. | PRD `DEC-008~011` | `P2-T1`, `P2-T2` |
|
||||||
|
| 2026-08-10 | `PLAN-DEC-002` | 확정 | 목록 구매를 홈·리스트형·썸네일형에서 모두 제거한다. | PRD `DEC-003`, `DEC-007` | `P1-T2` |
|
||||||
|
| 2026-08-10 | `PLAN-DEC-003` | 확정 | 백엔드 필드는 non-optional로 디코딩하고 미반영 응답을 fallback으로 숨기지 않는다. | PRD `DEC-006`, `EXT-001` | `P1-T1`, `P3-GATE` |
|
||||||
|
| 2026-08-10 | `PLAN-DEC-004` | 확정 | 신규 test target 대신 contract check·Preview·Debug build·시뮬레이터를 완료 증거로 사용한다. | 현재 프로젝트에 unit test bundle 없음 | 전체 Task·Gate |
|
||||||
|
| 2026-08-10 | `PLAN-DEC-005` | 확정 | 목록의 기존 구매 상태·API 호출을 상세 View·ViewModel로 옮긴다. | 상세의 현재 `showPurchaseError()`만으로는 사용자 요구인 상세 구매가 불가능함 | `P1-T2`, `P1-T3` |
|
||||||
|
| 2026-08-10 | `PLAN-DEC-006` | 확정 | 하트·댓글이 모두 숨겨지는 경우 높이 보정 대신 reaction bar 전체를 조건부로 제외한다. | 공용 리스트·상세의 빈 고정 높이를 같은 기존 상태로 제거하는 최소 변경 | `P2-R1`, `CCCI-009` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ISSUE-001` | High | 외부 조건 대기 | 백엔드 홈 API 응답에 `isCommentAvailable`·`isPinned`이 없으면 신규 iOS 모델 디코딩에 실패한다. | `P1-T1`, `P3-GATE` | iOS 구현·빌드는 완료했고 `EXT-001` 반영 후 통합 검증한다. |
|
||||||
|
| `ISSUE-002` | High | 환경 대기 | 사용 가능한 Simulator가 모두 `Shutdown`이라 실제 터치·메뉴·구매 검증을 실행하지 못했다. | `P3-T1`, `P3-GATE` | Simulator 부팅 후 수동 검증을 재개한다. |
|
||||||
|
| `ISSUE-003` | Medium | 수정 완료 | 유료 잠금 상태에서 하트·댓글은 숨겨지지만 빈 reaction bar의 고정 24pt 높이가 홈·목록·상세에 남는다. | `P2-R1`, `P3-GATE` | 조건부 삽입 contract check·Debug build 통과. |
|
||||||
|
|
||||||
|
## 최종 보고 형식
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
구현 결과: 홈·커뮤니티 아이템 통일과 상세 구매 일원화
|
||||||
|
|
||||||
|
- 변경: 홈 아이템 재사용, 목록 구매 제거, 더보기·상세·잠금 색상 통일
|
||||||
|
- 결정: `DEC-001~012`, `PLAN-DEC-001~005`
|
||||||
|
- 검증:
|
||||||
|
- 정적 contract check·`git diff --check` — 실제 결과
|
||||||
|
- `SodaLive-dev` Debug build — 실제 결과
|
||||||
|
- Preview·시뮬레이터 — 실제 결과 또는 외부 조건
|
||||||
|
- 남은 항목: `EXT-001` 반영 여부 또는 없음
|
||||||
|
- 문서: PRD·`plan-task.md`·Phase 3 review 리포트
|
||||||
|
```
|
||||||
208
docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/prd.md
Normal file
208
docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/prd.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 제품 요구사항 문서(PRD): 크리에이터 채널 커뮤니티 아이템 통일
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | reaction bar 회귀 수정·Debug build 완료·`EXT-001`/Simulator 통합 검증 대기 |
|
||||||
|
| 작성일 | 2026-08-10 |
|
||||||
|
| 최종 수정일 | 2026-08-10 |
|
||||||
|
| 대상 제품 | SodaLive iOS 크리에이터 채널 홈·커뮤니티 |
|
||||||
|
| 작성자·결정권자 | 사용자·iOS 구현 에이전트 |
|
||||||
|
| 관련 API Contract | `GET /api/v2/creator-channels/{creatorId}/home` 커뮤니티 응답 필드 확장 |
|
||||||
|
| 관련 구현 계획 | `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/plan-task.md` |
|
||||||
|
| 관련 review | `reviews/phase3-community-item-unification.md` |
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
크리에이터 채널 홈 탭의 커뮤니티 요약 아이템을 커뮤니티 탭 리스트와 동일한 `CreatorChannelCommunityListItem`으로 표시한다. 홈과 커뮤니티 탭의 아이템 터치는 게시글 상세로 이동하고, 유료 게시글 구매는 상세 화면에서만 시작한다. 유료 미구매 이미지 영역은 상세 화면의 `Color.gray800`과 동일하게 통일하고, 하트·댓글을 모두 표시하지 않는 상태에서는 reaction bar의 빈 높이도 제거한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- 홈 탭은 `CommunityPostCard`, 커뮤니티 탭은 `CreatorChannelCommunityListItem`을 사용해 동일한 게시글의 외형과 상호작용이 다르다.
|
||||||
|
- 홈 아이템에는 우측 상단 더보기가 없고 아이템 터치 상세 이동이 연결되지 않았다.
|
||||||
|
- 커뮤니티 탭의 유료 미구매 영역은 `Color.gray400`, 상세 화면은 `Color.gray800`을 사용해 색상이 다르다.
|
||||||
|
- 현재 `CreatorChannelCommunityListItem`의 유료 미구매 영역은 목록에서 구매 다이얼로그를 여는 별도 `Button`이므로, 전체 아이템을 상세 진입으로 통일하려는 요구와 충돌한다.
|
||||||
|
- 홈·커뮤니티 리스트와 상세는 하트·댓글을 모두 숨기는 유료 잠금 상태에서도 높이 24pt의 빈 reaction bar를 삽입해 불필요한 세로 공간이 남는다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
### 3.1 제품 목표
|
||||||
|
|
||||||
|
- 홈 탭과 커뮤니티 탭의 리스트형 커뮤니티 아이템 외형을 하나의 컴포넌트로 통일한다.
|
||||||
|
- 홈 탭 커뮤니티 아이템 우측 상단에 더보기를 제공한다.
|
||||||
|
- 커뮤니티 아이템 터치를 상세 진입으로 통일하고 구매는 상세 화면에서 처리한다.
|
||||||
|
- 유료 미구매 이미지 영역을 `Color.gray800`으로 통일한다.
|
||||||
|
- 하트·댓글을 모두 표시하지 않는 상태에서는 reaction bar가 레이아웃 공간을 차지하지 않게 한다.
|
||||||
|
|
||||||
|
### 3.2 UX 목표
|
||||||
|
|
||||||
|
- 홈에서 본 커뮤니티 아이템이 전체 커뮤니티 탭에서도 같은 정보 구조와 스타일로 보여야 한다.
|
||||||
|
- 아이템 본문·이미지·유료 잠금 영역을 터치하면 예상 가능하게 상세 화면으로 이동해야 한다.
|
||||||
|
- 더보기·좋아요 같은 독립 제어의 터치는 아이템 상세 터치와 중복 실행되지 않아야 한다.
|
||||||
|
- 표시할 reaction이 없으면 콘텐츠 아래에 빈 여백이 남지 않아야 한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- 기존 구매 endpoint와 `CommunityPostPurchaseDialog`의 내부 UI는 변경하지 않는다.
|
||||||
|
- 썸네일형 커뮤니티 아이템의 레이아웃은 변경하지 않는다.
|
||||||
|
- `CommunityPostCard`의 다른 사용처인 메인 홈 팔로잉·추천 섹션은 변경하지 않는다.
|
||||||
|
- 신규 endpoint, 앱 dependency, 범위 밖 커뮤니티 리팩터링은 추가하지 않는다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
- 타인 채널 방문자: 더보기에서 신고 동작을 사용할 수 있다.
|
||||||
|
- 본인 채널의 크리에이터: 기존 커뮤니티 게시글 관리 권한 범위 안에서 더보기 동작을 사용한다.
|
||||||
|
- 유료 미구매 방문자: 목록에서 구매하지 않고 상세 화면에서 구매를 시작한다.
|
||||||
|
|
||||||
|
## 6. 핵심 사용자 흐름
|
||||||
|
|
||||||
|
1. 사용자가 크리에이터 채널 홈의 커뮤니티 요약 아이템을 확인한다.
|
||||||
|
2. 아이템 본체를 터치하면 해당 `postId`의 커뮤니티 게시글 상세로 이동한다.
|
||||||
|
3. 유료 미구매 게시글이면 상세 화면에서 기존 구매 다이얼로그와 API를 사용한다.
|
||||||
|
4. 사용자가 우측 상단 더보기를 터치하면 확정된 권한 규칙에 맞는 메뉴가 표시된다.
|
||||||
|
|
||||||
|
## 7. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CCCI-001` | 확정 | 홈 탭 커뮤니티 섹션은 `CommunityPostCard` 대신 `CreatorChannelCommunityListItem`을 사용한다. | 홈과 커뮤니티 탭 리스트 아이템이 동일한 컴포넌트로 렌더링된다. | `P2-T1` |
|
||||||
|
| `CCCI-002` | 확정 | 홈 탭 커뮤니티 아이템 우측 상단에 더보기를 표시한다. | 더보기가 보이고 아이템 상세 터치와 독립적으로 작동한다. | `P2-T1` |
|
||||||
|
| `CCCI-003` | 확정 | 아이템 본체 터치는 `creatorChannelCommunityPostDetail`로 이동한다. | 선택한 게시글의 `postId`로 상세 API가 호출된다. | `P2-T1` |
|
||||||
|
| `CCCI-004` | 확정 | 목록에서 유료 게시글 구매를 시작하지 않고 상세에서 처리한다. | 유료 미구매 영역 터치는 상세로 이동하고 상세의 잠금 영역에서 구매 다이얼로그·API가 동작한다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `CCCI-005` | 확정 | 커뮤니티 탭 리스트형·썸네일형의 유료 미구매 영역을 `Color.gray800`으로 변경한다. | 홈·커뮤니티 탭 두 표시 모드·상세의 유료 미구매 영역이 `Color.gray800`을 사용한다. | `P1-T2` |
|
||||||
|
| `CCCI-006` | 확정 | 홈 탭 더보기는 커뮤니티 탭과 동일하게 본인 게시글에 고정·수정·삭제, 타인 게시글에 신고를 제공한다. | 권한별 메뉴가 커뮤니티 탭과 동일하고 성공 후 홈·커뮤니티 목록이 갱신된다. | `P2-T1`, `P2-T2` |
|
||||||
|
| `CCCI-007` | 확정 | 홈과 커뮤니티 탭의 리스트형·썸네일형 모두 목록 구매를 제거하고 유료 미구매 영역 터치를 상세 이동으로 처리한다. | 모든 목록 표시 모드에서 구매 다이얼로그가 열리지 않고 상세에서만 구매한다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `CCCI-008` | 외부 의존 | 백엔드 홈 API의 `communities`·`notices` 아이템에 `isCommentAvailable: Bool`·`isPinned: Bool`을 커뮤니티 탭과 동일하게 제공한다. | iOS가 두 필드를 임의 기본값 없이 디코딩해 고정·댓글 상태를 표시한다. | `P1-T1`, `EXT-001` |
|
||||||
|
| `CCCI-009` | 확정 | 홈·커뮤니티 리스트와 커뮤니티 상세에서 하트·댓글을 모두 표시하지 않을 때 reaction bar를 삽입하지 않는다. | 유료 미구매 잠금 상태에서 하트·댓글과 고정 24pt 높이가 모두 사라지고, 하나라도 표시되면 기존 reaction bar가 유지된다. | `P2-R1` |
|
||||||
|
|
||||||
|
## 8. UI/UX Expectations
|
||||||
|
|
||||||
|
- 홈 섹션은 기존처럼 최대 3개 아이템과 `전체보기` 버튼을 유지한다.
|
||||||
|
- 홈 아이템의 typography, spacing, profile image, 본문, media, reaction bar, radius는 `CreatorChannelCommunityListItem`의 현재 구성을 따른다.
|
||||||
|
- 우측 상단 더보기는 기존 `ic_seemore_vertical`을 재사용한다.
|
||||||
|
- 리스트형 유료 미구매 영역은 236pt 높이를, 썸네일형은 기존 정사각형 비율을 유지한다. 두 형태 모두 잠금 icon·가격 capsule을 유지하고 배경만 `Color.gray800`으로 통일한다.
|
||||||
|
- 더보기와 좋아요 제어는 자신의 action만 실행하고 상세 이동을 중복 실행하지 않는다.
|
||||||
|
- 유료 미구매 잠금 상태처럼 하트·댓글을 모두 숨기는 경우 reaction bar 전체를 조건부로 제외해 고정 높이와 `VStack` 간격을 남기지 않는다.
|
||||||
|
|
||||||
|
## 9. 기술·운영 제약
|
||||||
|
|
||||||
|
- 변경은 `SodaLive/Sources/V2/CreatorChannel/**`와 필요한 공용 V2 컴포넌트의 최소 범위에서 해결한다.
|
||||||
|
- `CommunityPostCard`의 메인 홈 사용처는 수정하지 않는다.
|
||||||
|
- 현재 상세 route와 기존 구매 다이얼로그·구매 API를 재사용한다.
|
||||||
|
- 백엔드는 홈 API 응답에 커뮤니티 탭과 동일한 `isCommentAvailable: Bool`·`isPinned: Bool`을 제공한다. iOS는 두 필드가 없는 응답을 추정 기본값으로 보정하지 않는다.
|
||||||
|
- 홈 응답 모델과 커뮤니티 탭 응답 모델의 차이를 제거하기 위한 투기적 protocol·factory·신규 dependency를 추가하지 않는다.
|
||||||
|
- 본인 게시글 판정은 기존처럼 `creatorId == UserDefaults.int(forKey: .userId)`를 사용한다.
|
||||||
|
|
||||||
|
## 10. 성공 기준
|
||||||
|
|
||||||
|
- [x] 홈 탭 커뮤니티 섹션이 `CreatorChannelCommunityListItem`을 사용한다.
|
||||||
|
- [ ] 홈 API의 커뮤니티 아이템이 `isCommentAvailable`·`isPinned`을 정상 디코딩하고 목록 표시에 반영한다.
|
||||||
|
- [ ] 홈 아이템 우측 상단에 더보기가 노출되고 확정된 권한 메뉴로 연결된다.
|
||||||
|
- [ ] 홈 리스트형과 커뮤니티 탭의 리스트형·썸네일형 아이템 본체를 터치하면 선택한 게시글 상세로 이동한다.
|
||||||
|
- [ ] 홈·리스트형·썸네일형 목록에서 구매 다이얼로그가 열리지 않고, 상세 잠금 영역에서 구매 다이얼로그·API가 동작한다.
|
||||||
|
- [x] 홈·리스트형·썸네일형·상세의 유료 미구매 영역이 모두 `Color.gray800`을 사용한다.
|
||||||
|
- [ ] 더보기·좋아요 터치가 상세 이동을 중복 발생시키지 않는다.
|
||||||
|
- [x] 홈·커뮤니티 리스트와 상세의 유료 미구매 상태에서 하트·댓글용 빈 공간이 남지 않는다.
|
||||||
|
- [x] `SodaLive-dev` 스킴 Debug 빌드가 성공한다.
|
||||||
|
- [ ] 시뮬레이터에서 홈·커뮤니티 탭·상세의 아이템 터치, 더보기, 유료 잠금 색상을 확인한다.
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
|
||||||
|
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 시점 | 영향 요구사항 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `OQ-001` | 확정 | 홈 아이템 더보기가 커뮤니티 탭과 동일하게 본인에게 고정·수정·삭제, 타인에게 신고를 제공한다. | A안 확정 | 사용자 | 2026-08-10 | `CCCI-002`, `CCCI-006` |
|
||||||
|
| `OQ-002` | 확정 | 목록 구매 제거를 홈과 커뮤니티 탭 모두에 적용한다. | A안 확정 | 사용자 | 2026-08-10 | `CCCI-004`, `CCCI-007` |
|
||||||
|
| `OQ-003` | 확정 | 백엔드 홈 API 응답에 `isCommentAvailable`·`isPinned`을 커뮤니티 탭과 동일하게 추가한다. | A안 확정 | 사용자·API 담당 | iOS 통합 검증 전 | `CCCI-001`, `CCCI-008`, `EXT-001` |
|
||||||
|
| `OQ-004` | 확정 | 홈 API의 날짜 키 `dateUtc`를 유지하고 iOS 로컬 변환에서 같은 UTC 문자열을 커뮤니티 탭 모델의 `createdAtUtc`로 연결한다. | A안 확정·날짜 값 파싱·시간대·포맷 변환 없음 | 사용자 | 2026-08-10 | `DEC-008` |
|
||||||
|
|
||||||
|
## 12. 외부 제공 대기 계약
|
||||||
|
|
||||||
|
| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 |
|
||||||
|
|---|---:|---|---|---|---|
|
||||||
|
| `EXT-001` | P0 | `GET /api/v2/creator-channels/{creatorId}/home` 응답의 `communities`·`notices` 아이템에 non-optional `isCommentAvailable: Bool`·`isPinned: Bool` 추가 | 백엔드 | 홈 아이템의 고정 표시·댓글 노출·고정/해제 메뉴 정확성 | 개발 서버 응답과 계약에 두 필드 반영 |
|
||||||
|
|
||||||
|
## 13. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | API Contract | 계획 Phase | 자동 검증 | 수동 검증 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CCCI-001~003`, `CCCI-006` | 홈 API 필드 확장·기존 목록·상세 API 재사용 | `P2-T1`, `P2-T2` | focused 구조 검증·Debug build | 홈/커뮤니티 탭 액션 확인 |
|
||||||
|
| `CCCI-004`, `CCCI-005`, `CCCI-007` | 기존 목록·상세·구매 API 재사용 | `P1-T2`, `P1-T3` | 목록 구매 제거·상세 구매 연결·`Color.gray800` 참조 검증·Debug build | 목록/상세 잠금 영역·상세 진입·구매 확인 |
|
||||||
|
| `CCCI-008` | 홈 API 필드 확장 | `P1-T1`, `P3-GATE`, `EXT-001` | decoding contract·Debug build | 백엔드 반영 후 홈 응답 확인 |
|
||||||
|
| `CCCI-009` | 기존 목록·상세 모델 재사용 | `P2-R1` | reaction bar 조건부 삽입 contract check·Debug build | 홈·커뮤니티 리스트·상세 유료 잠금 여백 확인 |
|
||||||
|
|
||||||
|
## 14. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-10 | `DEC-001` | 확정 | 홈 커뮤니티 요약 아이템은 `CreatorChannelCommunityListItem`을 사용한다. | 사용자 요청: 홈·커뮤니티 탭 외형 통일 | `CCCI-001` |
|
||||||
|
| 2026-08-10 | `DEC-002` | 확정 | 커뮤니티 아이템 우측 상단에 더보기를 제공한다. | 사용자 요청 | `CCCI-002` |
|
||||||
|
| 2026-08-10 | `DEC-003` | 확정 | 아이템 본체는 상세로 이동하고 구매는 상세에서 처리한다. | 사용자 요청 | `CCCI-003`, `CCCI-004` |
|
||||||
|
| 2026-08-10 | `DEC-004` | 확정 | 목록의 유료 미구매 영역은 상세와 동일한 `Color.gray800`을 사용한다. | 현재 커뮤니티 탭 `Color.gray400`, 상세 `Color.gray800` 차이 확인 | `CCCI-005` |
|
||||||
|
| 2026-08-10 | `DEC-005` | 확정 | 홈 더보기는 커뮤니티 탭과 동일하게 본인 게시글에 고정·수정·삭제, 타인 게시글에 신고를 제공한다. | 사용자 인터뷰 A안 선택 | `CCCI-002`, `CCCI-006`, `OQ-001` |
|
||||||
|
| 2026-08-10 | `DEC-006` | 확정 | 백엔드 홈 API 응답에 `isCommentAvailable`·`isPinned`을 커뮤니티 탭과 동일하게 제공한다. | 사용자 인터뷰 A안 선택·백엔드 동시 반영 예정 | `CCCI-008`, `OQ-003`, `EXT-001` |
|
||||||
|
| 2026-08-10 | `DEC-007` | 확정 | 홈과 커뮤니티 탭 모두 목록 구매를 제거하고 아이템 본체 터치를 상세 이동으로 통일한다. | 사용자 인터뷰 A안 선택 | `CCCI-004`, `CCCI-007`, `OQ-002` |
|
||||||
|
| 2026-08-10 | `DEC-008` | 확정 | 홈 API 모델을 커뮤니티 탭 아이템으로 로컬 변환하고 기존 `CreatorChannelCommunityListItem`·메뉴 상태·상세 route를 재사용한다. | 사용자가 접근법 1 선택·신규 공용 모델 없는 최소 변경 | `CCCI-001~008` |
|
||||||
|
| 2026-08-10 | `DEC-009` | 확정 | 홈에서 `CreatorChannelCommunityListItem`·기존 메뉴 상태·상세 route를 재사용하고, 목록 구매 제거·`Color.gray800` 통일·변경 성공 후 홈·커뮤니티 목록 갱신 설계를 확정한다. | 사용자 설계 승인·날짜 필드 연결 설명만 추가 확인 | `CCCI-001~007` |
|
||||||
|
| 2026-08-10 | `DEC-010` | 확정 | 홈 API의 `dateUtc`는 유지하고 iOS에서 값 변환 없이 `CreatorChannelCommunityPostItem.createdAtUtc`로 연결한다. | 사용자 A안 선택·기존 홈 API 계약 영향 최소화 | `DEC-008`, `OQ-004` |
|
||||||
|
| 2026-08-10 | `DEC-011` | 확정 | 승인된 컴포넌트·데이터 흐름과 최종 동작·오류·검증 설계를 구현 기준으로 사용한다. | 사용자 최종 설계 승인 | `CCCI-001~008`, `EXT-001` |
|
||||||
|
| 2026-08-10 | `DEC-012` | 확정 | 목록에 있던 기존 구매 다이얼로그·API 호출 소유권을 상세 화면으로 옮긴다. | 구현 전 기준선에서 상세 잠금 버튼이 구매가 아닌 `showPurchaseError()`만 호출함을 확인·사용자 직접 요구 충족 | `CCCI-004`, `CCCI-007` |
|
||||||
|
| 2026-08-10 | `DEC-013` | 확정 | 하트·댓글이 모두 숨겨지는 유료 잠금 상태에서는 고정 높이를 보정하지 않고 reaction bar 전체를 조건부로 삽입하지 않는다. | 목록·상세의 공통 원인은 빈 `HStack`의 `.frame(height: 24)` 유지이며, 상위 조건부 삽입이 가장 작은 근본 수정이다. | `CCCI-009`, `P2-R1` |
|
||||||
|
|
||||||
|
## 15. 구현 설계
|
||||||
|
|
||||||
|
### 15.1 데이터 연결
|
||||||
|
|
||||||
|
- 홈 API 모델 `CreatorChannelCommunityPostResponse`에 non-optional `isCommentAvailable: Bool`·`isPinned: Bool`을 추가한다.
|
||||||
|
- 백엔드는 같은 응답 모델을 사용하는 `notices`·`communities` 모두에 두 필드를 제공한다.
|
||||||
|
- `CreatorChannelCommunitySection`의 private 로컬 변환에서 홈 게시글을 `CreatorChannelCommunityPostItem`으로 연결한다. 신규 공용 protocol·factory·표시 모델은 만들지 않는다.
|
||||||
|
- 날짜는 `createdAtUtc: homePost.dateUtc`로 같은 UTC 문자열을 대입한다. 파싱, 시간대, 포맷을 변경하지 않는다.
|
||||||
|
|
||||||
|
### 15.2 컴포넌트와 터치
|
||||||
|
|
||||||
|
- `CreatorChannelCommunitySection`은 기존처럼 최대 3개와 `전체보기`를 유지하되 각 아이템을 `CreatorChannelCommunityListItem`으로 렌더링한다.
|
||||||
|
- 홈은 `CreatorChannelView`의 `isOwnCreatorChannel`과 기존 사용자 id 비교 결과를 아이템에 전달한다.
|
||||||
|
- `CreatorChannelCommunityListItem`과 `CreatorChannelCommunityGridItem`에서 `onTapPurchase`를 제거한다. 유료 미구매 영역은 `Button`이 아닌 표시 View로 렌더링해 아이템의 `onTapDetail`을 사용한다.
|
||||||
|
- 리스트형·썸네일형 유료 미구매 영역 배경은 상세와 동일한 `Color.gray800`을 사용한다. 기존 잠금 icon·가격 capsule·크기는 유지한다.
|
||||||
|
- 상세 진입 helper는 표시 모델 전체 대신 `postId` 만 받아 홈·리스트형·썸네일형이 같은 route를 사용한다.
|
||||||
|
- 더보기·좋아요 `Button`은 각자의 action만 실행하고 부모 아이템의 상세 진입을 중복 실행하지 않아야 한다.
|
||||||
|
- `CreatorChannelCommunityListItem`과 `CreatorChannelCommunityPostDetailContentView`는 `isPaidLocked`가 `true`이면 `reactionBar` 자체를 View 계층에 삽입하지 않는다. 댓글만 비활성이고 좋아요가 표시되는 상태는 기존 reaction bar를 유지한다.
|
||||||
|
|
||||||
|
### 15.3 더보기와 변경 후 갱신
|
||||||
|
|
||||||
|
- 홈 아이템 더보기는 로컬 변환한 `CreatorChannelCommunityPostItem`을 `CreatorChannelCommunityViewModel.openReportMenu(post:)`에 전달한다.
|
||||||
|
- `CreatorChannelView.communityOverlay` 노출 범위를 홈과 커뮤니티 탭으로 확장해 기존 고정·수정·삭제·신고 UI를 재사용한다.
|
||||||
|
- 고정·수정·삭제 성공 후 `CreatorChannelViewModel.fetchHome(creatorId:)`와 `CreatorChannelCommunityViewModel.fetchFirstPage(creatorId:)`를 모두 호출해 두 표시 위치를 동기화한다.
|
||||||
|
- 목록 구매 호출처를 제거한 후 `CreatorChannelCommunityViewModel`의 목록 구매 상태·메서드와 `CreatorChannelView.communityOverlay`의 구매 dialog 분기를 이번 변경이 만든 orphan으로서 제거한다.
|
||||||
|
- `CreatorChannelCommunityPostDetailView`의 잠금 영역은 기존 `CommunityPostPurchaseDialog`를 표시하고, `CreatorChannelCommunityPostDetailViewModel`이 기존 `CreatorCommunityRepository.purchaseCommunityPost(postId:)`를 호출한다.
|
||||||
|
- 상세 구매 성공 후 상세를 다시 조회하고 `onCommunityRefresh`를 호출해 홈·커뮤니티 목록도 갱신한다. endpoint와 dialog 내부 UI는 변경하지 않는다.
|
||||||
|
|
||||||
|
### 15.4 오류와 외부 의존
|
||||||
|
|
||||||
|
- 고정·수정·삭제·신고 실패는 기존 `CreatorChannelCommunityViewModel`과 `CreatorChannelViewModel`의 toast 흐름을 유지한다.
|
||||||
|
- `isCommentAvailable`·`isPinned`이 없는 홈 응답을 기본값으로 보정하지 않는다. `EXT-001` 미반영 환경의 디코딩 실패는 기존 홈 API 실패 상태로 처리한다.
|
||||||
|
- iOS 구현·빌드는 백엔드 반영과 독립적으로 진행할 수 있지만, 통합·시뮬레이터 완료는 `EXT-001` 반영 후에만 판정한다.
|
||||||
|
|
||||||
|
### 15.5 검증 설계
|
||||||
|
|
||||||
|
- 현재 앱 타겟에 실행 가능한 단일 test bundle이 없으므로 이 변경만을 위한 신규 test 타겟이나 dependency를 추가하지 않는다.
|
||||||
|
- 대체 검증으로 홈 섹션·리스트형·썸네일형 Preview에서 유료 미구매·고정·댓글 비활성 상태를 구성한다.
|
||||||
|
- 정적 검증은 `rg`로 목록 `onTapPurchase`·목록 구매 dialog 호출처 제거, 상세 구매 dialog·API 연결, `Color.gray800`, 홈·목록·썸네일 상세 route 연결을 확인하고 `git diff --check`를 실행한다.
|
||||||
|
- reaction bar 회귀 검증은 두 대상 View의 body에 무조건 삽입된 `reactionBar`가 없고 `if !isPaidLocked` 내부에만 삽입되는지 focused `rg`로 확인한다.
|
||||||
|
- 자동 검증은 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 컴파일·링크 성공을 확인한다.
|
||||||
|
- `EXT-001` 반영 후 시뮬레이터에서 홈·리스트형·썸네일형의 상세 진입, 더보기 권한 메뉴, 목록 구매 dialog 미노출, 상세 구매, `Color.gray800`을 수동 확인한다.
|
||||||
|
|
||||||
|
## 16. 검증 기록
|
||||||
|
|
||||||
|
- 2026-08-10: `CreatorChannelCommunitySection`이 `CommunityPostCard`를 사용하고 `CreatorChannelCommunityListItem`은 커뮤니티 탭의 별도 응답 모델을 사용함을 확인했다.
|
||||||
|
- 2026-08-10: 홈 응답에는 `isCommentAvailable`·`isPinned`이 없고, 커뮤니티 탭 응답에는 두 필드가 있음을 확인했다.
|
||||||
|
- 2026-08-10: `CreatorChannelCommunityListItem.paidLockedImage`은 `Color.gray400`, `CreatorChannelCommunityPostDetailContentView.mediaContent`의 유료 미구매 영역은 `Color.gray800`을 사용함을 확인했다.
|
||||||
|
- 2026-08-10: 기존 문서 `docs/20260701_크리에이터_채널_홈`, `docs/20260705_크리에이터_채널_커뮤니티_탭`, `docs/20260707_커뮤니티_게시글_상세`를 확인했고 이번 교차 변경은 신규 문서 하나에서 추적한다.
|
||||||
|
- 2026-08-10: 사용자 인터뷰로 홈 더보기의 동일 권한 메뉴, 백엔드 홈 응답의 `isCommentAvailable`·`isPinned` 추가, 홈·커뮤니티 탭 모두의 목록 구매 제거를 확정했다.
|
||||||
|
- 2026-08-10: 구현 전 기준 빌드는 성공했고, 상세 잠금 영역이 실제 구매 대신 `showPurchaseError()`를 호출하는 계획 누락을 발견해 `DEC-012`와 상세 구매 이동 Task를 추가했다.
|
||||||
|
- 2026-08-10: 홈 응답 필드·로컬 변환·기존 리스트 아이템·상세/더보기 route·목록 동기화와 상세 구매 이동을 구현했다.
|
||||||
|
- 2026-08-10: 목록 구매 심볼·잠금 `Color.gray400`·`showPurchaseError` 제거와 상세 구매 dialog/API 연결을 `rg`로 확인했고 `git diff --check`가 통과했다.
|
||||||
|
- 2026-08-10: iPhone 17 Pro iOS 26.0 대상 `SodaLive-dev` Debug build가 exit code 0으로 성공했다.
|
||||||
|
- 2026-08-10: 사용 가능한 Simulator가 모두 `Shutdown`이고 `EXT-001` 반영 여부를 확인할 수 없어 실제 터치·메뉴·구매 통합 검증은 대기한다.
|
||||||
|
- 2026-08-10: 사용자 제보를 따라 목록·상세를 추적한 결과, 유료 잠금 상태에서도 빈 `reactionBar`의 `.frame(height: 24)`가 유지되는 공통 원인을 확인하고 `CCCI-009`, `DEC-013`, `P2-R1`로 회귀 수정 범위를 확정했다.
|
||||||
|
- 2026-08-10: 두 body의 무조건 `reactionBar` 삽입을 `if !isPaidLocked` 조건부 삽입으로 변경했다. focused `rg`에서 무조건 삽입 0건·조건부 삽입 2건, `git diff --check` 오류 0건, 동일 iPhone 17 Pro 대상 `SodaLive-dev` Debug build exit code 0을 확인했다.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# 코드 리뷰 보고서: 커뮤니티 아이템 통일
|
||||||
|
|
||||||
|
## 1. 리뷰 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 리뷰 대상 | Phase 1~2 / `P1-T1~P2-T2` |
|
||||||
|
| 기준 commit 또는 working tree | `89c070875cbeea8395c1e90cb9f47ff80df5d90f` 이후 미커밋 변경 |
|
||||||
|
| 리뷰 일자 | 2026-08-10 |
|
||||||
|
| 리뷰어 | Codex 자가 리뷰—프로젝트 보수적 실행 정책에 따라 subagent 미사용 |
|
||||||
|
| 기준 문서 | `docs/20260810_크리에이터_채널_커뮤니티_아이템_통일/prd.md`, `plan-task.md` |
|
||||||
|
| 리뷰 상태 | `REV-P3-003` 수정 완료·외부 통합 검증 대기 |
|
||||||
|
|
||||||
|
## 2. 리뷰 목적과 범위
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
- 홈·커뮤니티 탭의 아이템, 상세 진입, 더보기, 목록 구매 제거, 상세 구매 이동이 `CCCI-001~008`을 충족하는지 확인한다.
|
||||||
|
- 코드·문서·검증 기록이 실제 working tree와 일치하는지 확인한다.
|
||||||
|
|
||||||
|
### 포함 범위
|
||||||
|
|
||||||
|
- 코드: `SodaLive/Sources/V2/CreatorChannel/Home/**`, `Community/**`, `CreatorChannelView.swift`의 변경분
|
||||||
|
- 검증: 정적 contract check, `git diff --check`, `SodaLive-dev` Debug Simulator build
|
||||||
|
- 수동 검증: 부팅된 Simulator와 `EXT-001` 반영 여부 확인
|
||||||
|
|
||||||
|
### 제외 범위
|
||||||
|
|
||||||
|
- 백엔드 구현·배포
|
||||||
|
- 구매 endpoint와 `CommunityPostPurchaseDialog` 내부 UI
|
||||||
|
- `CommunityPostCard`의 메인 홈 사용처
|
||||||
|
|
||||||
|
## 3. 판정 기준
|
||||||
|
|
||||||
|
| 심각도 | 기준 |
|
||||||
|
|---|---|
|
||||||
|
| Blocker | 핵심 상세·구매·관리 흐름이 불가능하거나 완료 판정을 무효화함 |
|
||||||
|
| High | 확정 요구사항 또는 API 계약 위반 |
|
||||||
|
| Medium | 제한된 조건의 기능·상태 동기화 문제 |
|
||||||
|
| Low | 문서 정합성 또는 비핵심 유지보수 문제 |
|
||||||
|
|
||||||
|
| 상태 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 확정 | 코드·명령 근거로 문제 확인 |
|
||||||
|
| 오탐 | 요구사항·실행 결과상 문제가 아님 |
|
||||||
|
| 보류 | 외부 계약·환경 반영 후 판정 가능 |
|
||||||
|
| 수정 완료 | 수정과 관련 검증 완료 |
|
||||||
|
|
||||||
|
## 4. 검토한 근거
|
||||||
|
|
||||||
|
### 문서와 코드
|
||||||
|
|
||||||
|
- 요구사항: `CCCI-001~009`, `DEC-001~013`, `EXT-001`
|
||||||
|
- 계획: `P1-T1~P2-T2`, `P2-R1`, `P1-GATE`, `P2-GATE`
|
||||||
|
- 홈 변환·아이템: `CreatorChannelCommunitySection.swift:3`
|
||||||
|
- 목록 잠금·상세 gesture: `CreatorChannelCommunityListItem.swift`, `CreatorChannelCommunityGridItem.swift`
|
||||||
|
- 상세 구매: `CreatorChannelCommunityPostDetailView.swift:19`, `CreatorChannelCommunityPostDetailViewModel.swift:186`
|
||||||
|
- 메뉴·route·동기화: `CreatorChannelView.swift:480`, `CreatorChannelView.swift:605`
|
||||||
|
|
||||||
|
### 실행 환경
|
||||||
|
|
||||||
|
```text
|
||||||
|
OS: macOS 26.0 (25A354)
|
||||||
|
Xcode: 26.0 (17A324)
|
||||||
|
Scheme: SodaLive-dev / Debug
|
||||||
|
Destination: iPhone 17 Pro, iOS 26.0 Simulator
|
||||||
|
Backend: EXT-001 반영 여부를 이 환경에서 확인하지 못함
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행한 검증
|
||||||
|
|
||||||
|
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 홈 모델·변환·route·menu `rg` contract check | 성공 | 필드·`communityPostItem`·`CreatorChannelCommunityListItem`·`postId` route match |
|
||||||
|
| 목록 구매 심볼·잠금 배경 `rg` contract check | 성공 | 목록 구매 심볼과 잠금 `Color.gray400` 0건, 목록·상세 `Color.gray800` match |
|
||||||
|
| 상세 구매 `rg` contract check | 성공 | `showPurchaseError` 0건, 상세 dialog·repository 호출 match |
|
||||||
|
| reaction bar 조건부 삽입 `rg` contract check | 성공 | 무조건 삽입 0건, `if !isPaidLocked` 조건부 삽입 2건 |
|
||||||
|
| `git diff --check` | 성공 | whitespace 오류 0건 |
|
||||||
|
| `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -destination 'platform=iOS Simulator,id=E9FC7721-AA96-440F-8349-2DC4B85F40F5' -derivedDataPath /tmp/sodalive-community-baseline CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES build` | 성공 | exit code 0 |
|
||||||
|
| `xcrun simctl list devices` | 수동 검증 불가 | 모든 사용 가능한 Simulator가 `Shutdown`; 스킬 정책상 임의 부팅하지 않음 |
|
||||||
|
|
||||||
|
## 5. 발견 사항 요약
|
||||||
|
|
||||||
|
사용자 회귀 제보로 확정한 reaction bar 빈 공간 1건은 수정·빌드를 완료했다.
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `REV-P3-001` | High | 수정 완료 | 상세 잠금 버튼이 구매 대신 공통 오류만 표시하던 계획 누락 | `P1-T3` | 완료 |
|
||||||
|
| `REV-P3-002` | High | 보류 | 홈 API 신규 필드와 런타임 동작의 통합 검증 필요 | `P3-GATE`, `EXT-001` | 외부 반영 후 재개 |
|
||||||
|
| `REV-P3-003` | Medium | 수정 완료 | 하트·댓글이 숨겨져도 빈 reaction bar가 24pt 공간을 차지함 | `P2-R1` | 완료 |
|
||||||
|
|
||||||
|
## 6. 발견 사항 상세
|
||||||
|
|
||||||
|
### REV-P3-001 — 상세 구매 연결 누락
|
||||||
|
|
||||||
|
- **심각도:** High
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `CCCI-004`, `CCCI-007`
|
||||||
|
- **소유 Task:** `P1-T3`
|
||||||
|
|
||||||
|
기준선에서 상세 잠금 영역은 `showPurchaseError()`만 호출해 목록 구매 제거 후 실제 구매가 불가능했다. 코드 수정 전에 PRD `DEC-012`와 계획 `P1-T3`를 추가하고, 기존 `CommunityPostPurchaseDialog`·`CreatorCommunityRepository.purchaseCommunityPost(postId:)`를 상세 View·ViewModel로 이동했다. 정적 contract check와 Debug build가 통과했다.
|
||||||
|
|
||||||
|
### REV-P3-002 — 백엔드·Simulator 통합 검증 대기
|
||||||
|
|
||||||
|
- **심각도:** High
|
||||||
|
- **상태:** 보류
|
||||||
|
- **관련 요구사항:** `CCCI-001~008`, `EXT-001`
|
||||||
|
- **소유 Task:** `P3-GATE`
|
||||||
|
|
||||||
|
백엔드가 홈 API `notices`·`communities`에 `isCommentAvailable`·`isPinned`을 제공해야 하며, 실제 터치·메뉴·구매 흐름은 부팅된 Simulator에서 확인해야 한다. 현재 Simulator는 모두 종료 상태다. 백엔드 반영과 Simulator 부팅 후 홈·리스트·썸네일·상세 흐름을 재검증한다.
|
||||||
|
|
||||||
|
### REV-P3-003 — 표시할 reaction이 없을 때 빈 공간 유지
|
||||||
|
|
||||||
|
- **심각도:** Medium
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `CCCI-009`
|
||||||
|
- **소유 Task:** `P2-R1`
|
||||||
|
|
||||||
|
`CreatorChannelCommunityListItem`과 `CreatorChannelCommunityPostDetailContentView`는 유료 잠금 상태에서 하트·댓글 자식만 숨기고, body에는 `reactionBar`를 항상 삽입했다. 그 결과 빈 `HStack`의 `.frame(height: 24)`와 부모 `VStack` spacing이 홈·커뮤니티 리스트·상세에 남았다. 코드 수정 전에 `P2-R1`을 추가하고, 두 body에서 기존 `isPaidLocked`로 reaction bar 전체를 조건부 제외했다. focused contract check·`git diff --check`·Debug build가 통과했다.
|
||||||
|
|
||||||
|
## 7. 확정 항목의 plan·goal 전환
|
||||||
|
|
||||||
|
- `REV-P3-001`: 코드 수정 전에 `P1-T3`로 전환했고 수정·빌드를 완료했다.
|
||||||
|
- `REV-P3-003`: 코드 수정 전에 `P2-R1`로 전환했고 수정·검증을 완료했다.
|
||||||
|
- `REV-P3-002`: 외부 조건이므로 `P3-GATE` 완료를 보류한다.
|
||||||
|
|
||||||
|
## 8. 리뷰 종료 판정
|
||||||
|
|
||||||
|
| 판정 항목 | 결과 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 리뷰 범위 전체 확인 | 충족 | 사용자 회귀 제보와 수정 결과를 추가 반영 |
|
||||||
|
| 후보 항목 판정 완료 | 충족 | `REV-P3-001`·`REV-P3-003` 수정 완료, `REV-P3-002` 보류 |
|
||||||
|
| 확정 항목 plan 반영 | 충족 | `P1-T3`, `P2-R1`, `DEC-012~013` |
|
||||||
|
| 보류 항목의 담당·재개 조건 기록 | 충족 | `EXT-001`·부팅된 Simulator |
|
||||||
|
| 검증 명령과 결과 기록 | 충족 | 정적 계약·Debug build exit 0 |
|
||||||
|
|
||||||
|
**최종 결론:** iOS reaction bar 회귀 수정·빌드 완료, 외부 통합 검증 대기
|
||||||
|
|
||||||
|
**남은 항목:** 백엔드 `EXT-001` 반영 후 부팅된 Simulator에서 본체 상세 이동, 권한별 더보기, 목록 dialog 미노출, 상세 구매, `Color.gray800`을 확인한다.
|
||||||
|
|
||||||
|
## 9. 수정 후 검증 기록
|
||||||
|
|
||||||
|
### 1차 수정 검증 — 2026-08-10
|
||||||
|
|
||||||
|
- 무엇을: `REV-P3-001`의 상세 구매 누락을 수정했다.
|
||||||
|
- 왜: 목록 구매 제거 후에도 사용자가 상세에서 실제 구매할 수 있어야 한다.
|
||||||
|
- 어떻게:
|
||||||
|
- 상세 구매 contract check — 성공
|
||||||
|
- `SodaLive-dev` 단일 Simulator Debug build — exit code 0
|
||||||
|
- 남은 항목: `REV-P3-002`
|
||||||
|
|
||||||
|
### 2차 수정 검증 — 2026-08-10
|
||||||
|
|
||||||
|
- 무엇을: `REV-P3-003`의 빈 reaction bar 공간을 제거했다.
|
||||||
|
- 왜: 하트·댓글을 모두 숨기는 유료 잠금 상태에서 고정 24pt 높이와 부모 spacing까지 없어져야 한다.
|
||||||
|
- 어떻게:
|
||||||
|
- 무조건 `reactionBar` 삽입 contract check — 0건
|
||||||
|
- `if !isPaidLocked` 조건부 삽입 contract check — 2건
|
||||||
|
- `git diff --check` — 성공
|
||||||
|
- `SodaLive-dev` 단일 Simulator Debug build — exit code 0
|
||||||
|
- 남은 항목: `REV-P3-002`와 Simulator 시각 검증
|
||||||
Reference in New Issue
Block a user