feat(creator): 커뮤니티 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-05 22:25:07 +09:00
parent 130b8eee40
commit f3b7d01a3f
20 changed files with 1706 additions and 2 deletions

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct CreatorChannelCommunityEmptyView: View {
var body: some View {
VStack(spacing: 0) {
Text(I18n.CreatorChannelCommunity.communityEmpty)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.lineSpacing(SodaSpacing.s4)
}
.frame(maxWidth: .infinity)
.padding(.top, 316)
.padding(.bottom, 120)
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct CreatorChannelCommunityGridItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let onTapPurchase: () -> Void
var body: some View {
GeometryReader { proxy in
ZStack {
if isPaidLocked {
Button(action: onTapPurchase) {
Color.gray400
.overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width))
.frame(width: proxy.size.width, height: proxy.size.width)
.aspectRatio(1, contentMode: .fill)
.clipped()
} else {
Color.gray900
Text(fallbackText)
.appFont(size: 12, weight: .medium)
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(SodaSpacing.s8)
}
}
.frame(width: proxy.size.width, height: proxy.size.width)
.clipped()
.overlay(alignment: .topTrailing) {
if post.isPinned {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.padding(6)
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
.allowsHitTesting(false)
}
}
}
.aspectRatio(1, contentMode: .fit)
}
private var paidPriceOverlay: some View {
VStack(spacing: SodaSpacing.s4) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var fallbackText: String {
let content = post.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return "-" }
return String(content.prefix(18))
}
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
import Kingfisher
import SDWebImageSwiftUI
struct CreatorChannelCommunityListItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let isOwnCreatorChannel: Bool
let onTapLike: () -> Void
let onTapComment: () -> Void
let onTapMore: () -> Void
let onTapPurchase: () -> Void
@State private var localIsLike: Bool
@State private var localLikeCount: Int
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
@StateObject private var contentPlayManager = ContentPlayManager.shared
init(
post: CreatorChannelCommunityPostItem,
isOwnPost: Bool,
isOwnCreatorChannel: Bool,
onTapLike: @escaping () -> Void,
onTapComment: @escaping () -> Void,
onTapMore: @escaping () -> Void,
onTapPurchase: @escaping () -> Void
) {
self.post = post
self.isOwnPost = isOwnPost
self.isOwnCreatorChannel = isOwnCreatorChannel
self.onTapLike = onTapLike
self.onTapComment = onTapComment
self.onTapMore = onTapMore
self.onTapPurchase = onTapPurchase
_localIsLike = State(initialValue: post.isLiked ?? false)
_localLikeCount = State(initialValue: post.likeCount)
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
if post.isPinned {
pinnedLabel
}
header
contentText
if isPaidLocked {
paidLockedImage
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
imageContent(imageUrl: imageUrl)
}
reactionBar
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.onChange(of: post.isLiked) { newValue in
localIsLike = newValue ?? false
}
.onChange(of: post.likeCount) { newValue in
localLikeCount = newValue
}
}
private var pinnedLabel: some View {
HStack(spacing: 2) {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("Notice")
.appFont(size: 14, weight: .medium)
.foregroundColor(Color(hex: "73FF01"))
}
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 42, height: 42)
VStack(alignment: .leading, spacing: 2) {
Text(post.creatorNickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(post.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
Spacer(minLength: 0)
if shouldShowOwnerControls {
HStack(spacing: SodaSpacing.s4) {
if post.price > 0 {
priceTag
}
moreButton
}
} else if !isOwnPost {
moreButton
}
}
}
private var priceTag: some View {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.price)")
.appFont(size: 12, weight: .regular)
.foregroundColor(.black)
}
.padding(.horizontal, SodaSpacing.s4)
.padding(.vertical, 2)
.background(Color.white)
.clipShape(Capsule())
}
private var moreButton: some View {
Button(action: onTapMore) {
Image("ic_seemore_vertical")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
}
private var contentText: some View {
Text(post.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.lineLimit(5)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func imageContent(imageUrl: String) -> some View {
ZStack {
WebImage(url: URL(string: imageUrl))
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.background(Color.gray800)
if let audioUrl = post.audioUrl, !audioUrl.isEmpty {
audioPlayButton(audioUrl: audioUrl)
}
}
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var paidLockedImage: some View {
Button(action: onTapPurchase) {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray400)
.frame(maxWidth: .infinity)
.frame(height: 236)
.overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
}
private var paidPriceOverlay: some View {
VStack(spacing: SodaSpacing.s4) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
HStack(spacing: SodaSpacing.s6) {
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private func audioPlayButton(audioUrl: String) -> some View {
Button {
contentPlayManager.pauseAudio()
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: post.postId, url: audioUrl))
} label: {
Image(playManager.isPlaying && playManager.currentPlayingContentId == post.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
.resizable()
.frame(width: 48, height: 48)
}
.buttonStyle(.plain)
}
private var reactionBar: some View {
HStack(spacing: 15) {
if !isPaidLocked {
Button {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
localLikeCount = max(0, localLikeCount)
onTapLike()
} label: {
HStack(spacing: SodaSpacing.s4) {
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
.resizable()
.frame(width: 18, height: 18)
Text("\(localLikeCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
.buttonStyle(.plain)
}
if post.isCommentAvailable && !isPaidLocked {
Button(action: onTapComment) {
HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.commentCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
.buttonStyle(.plain)
}
}
.frame(height: 24)
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var shouldShowOwnerControls: Bool {
isOwnCreatorChannel && isOwnPost
}
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct CreatorChannelCommunityUploadButton: View {
let action: () -> Void
var body: some View {
VStack {
Button(action: action) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_upload_community_post")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(I18n.CreatorChannelHome.uploadCommunityPost)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.padding(.horizontal, SodaSpacing.s14)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.padding(.top, SodaSpacing.s14)
.padding(.bottom, 34)
.background(Color.black)
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct CreatorChannelCommunityViewModeBar: View {
let communityPostCount: Int
let viewMode: CreatorChannelCommunityViewMode
let onTapToggle: () -> Void
var body: some View {
HStack(alignment: .center, spacing: SodaSpacing.s8) {
HStack(spacing: SodaSpacing.s4) {
Text(I18n.CreatorChannelCommunity.totalLabel)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
Text("\(communityPostCount)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
}
Spacer()
Button(action: onTapToggle) {
HStack(spacing: SodaSpacing.s4) {
Text(viewModeTitle)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
Image(viewModeIconName)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 52)
.background(Color.black)
}
private var viewModeTitle: String {
switch viewMode {
case .list:
return I18n.CreatorChannelCommunity.listView
case .thumbnail:
return I18n.CreatorChannelCommunity.thumbnailView
}
}
private var viewModeIconName: String {
switch viewMode {
case .list:
return "ic_new_list"
case .thumbnail:
return "ic_new_grid"
}
}
}

View File

@@ -0,0 +1,128 @@
import SwiftUI
struct CreatorChannelCommunityTabView: View {
let creatorId: Int
let isOwnCreatorChannel: Bool
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
private let gridColumns = [
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0)
]
var body: some View {
content
.background(Color.black)
.onAppear {
if viewModel.hasLoaded == false {
viewModel.fetchFirstPage(creatorId: creatorId)
}
}
.sheet(isPresented: $viewModel.isShowCommentListView) {
NavigationStack {
CreatorCommunityCommentListView(
isPresented: $viewModel.isShowCommentListView,
creatorId: creatorId,
postId: viewModel.selectedPostId,
isShowSecret: viewModel.isShowSecret
)
}
.toolbar(.hidden, for: .navigationBar)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
private var content: some View {
VStack(spacing: 0) {
if isEmptyState {
CreatorChannelCommunityEmptyView()
} else {
CreatorChannelCommunityViewModeBar(
communityPostCount: viewModel.communityPostCount,
viewMode: viewModel.viewMode,
onTapToggle: viewModel.toggleViewMode
)
if viewModel.viewMode == .list {
listContent
} else {
gridContent
}
}
}
}
private var listContent: some View {
LazyVStack(spacing: SodaSpacing.s8) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityListItem(
post: post,
isOwnPost: isOwnPost,
isOwnCreatorChannel: isOwnCreatorChannel,
onTapLike: {
viewModel.communityPostLike(postId: post.postId)
},
onTapComment: {
viewModel.openCommentList(post: post)
},
onTapMore: {
viewModel.openReportMenu(post: post)
},
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.bottom, contentBottomPadding)
}
private var gridContent: some View {
LazyVGrid(columns: gridColumns, spacing: 0) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityGridItem(
post: post,
isOwnPost: isOwnPost,
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.bottom, contentBottomPadding)
}
@ViewBuilder
private var loadingNextPageView: some View {
if viewModel.isLoadingNextPage {
ProgressView()
.padding(.vertical, SodaSpacing.s20)
}
}
private var isEmptyState: Bool {
guard viewModel.hasLoaded else { return false }
return viewModel.communityPostCount == 0
}
private var contentBottomPadding: CGFloat {
isOwnCreatorChannel ? 120 : SodaSpacing.s20
}
}

View File

@@ -0,0 +1,351 @@
import Foundation
import Combine
import Moya
enum CreatorChannelCommunityViewMode {
case list
case thumbnail
}
final class CreatorChannelCommunityViewModel: ObservableObject {
private let repository = CreatorChannelCommunityRepository()
private let communityRepository = CreatorCommunityRepository()
private let reportRepository = ReportRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var response: CreatorChannelCommunityTabResponse?
@Published var communityPosts = [CreatorChannelCommunityPostItem]()
@Published var viewMode: CreatorChannelCommunityViewMode = .list
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowCommentListView = false {
didSet {
if !isShowCommentListView {
selectedPostId = 0
isShowSecret = false
}
}
}
@Published var isShowReportMenu = false
@Published var isShowReportView = false {
didSet {
if !isShowReportView {
selectedPostId = 0
}
}
}
@Published var isShowDeleteConfirm = false {
didSet {
if !isShowDeleteConfirm {
selectedPostId = 0
}
}
}
@Published var isShowPostPurchaseView = false {
didSet {
if !isShowPostPurchaseView {
selectedPostId = 0
selectedPostPrice = 0
}
}
}
@Published var selectedPostId = 0
@Published var selectedPostPrice = 0
@Published var selectedPostIsPinned = false
@Published var isShowSecret = false
var communityPostCount: Int {
response?.communityPostCount ?? 0
}
func fetchFirstPage(creatorId: Int) {
fetchCommunity(creatorId: creatorId, page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelCommunityPostItem) {
guard communityPosts.last?.postId == currentItem.postId else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
fetchCommunity(creatorId: creatorId, page: page + 1, isNextPage: true)
}
func toggleViewMode() {
viewMode = viewMode == .list ? .thumbnail : .list
}
func isPaidLocked(_ post: CreatorChannelCommunityPostItem, isOwnPost: Bool) -> Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
func openCommentList(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
isShowSecret = post.price > 0 && post.existOrdered && post.creatorId != UserDefaults.int(forKey: .userId)
isShowCommentListView = true
}
func openReportMenu(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
selectedPostIsPinned = post.isPinned
isShowReportMenu = true
}
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
isShowPostPurchaseView = true
}
func communityPostLike(postId: Int) {
communityRepository.communityPostLike(postId: postId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { _ in }
.store(in: &subscription)
}
func report(reason: String) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ReportRequest(type: .COMMUNITY_POST, reason: reason, communityPostId: selectedPostId)
reportRepository.report(request: request)
.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)
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 deleteCommunityPost(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ModifyCommunityPostRequest(creatorCommunityId: selectedPostId, isActive: false)
var multipartData = [MultipartFormData]()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
guard let jsonData = try? encoder.encode(request) else {
applyFailureState()
return
}
multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request"))
communityRepository.modifyCommunityPost(parameters: multipartData)
.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.errorMessage = I18n.Explorer.deleted
self.isShowPopup = true
self.fetchFirstPage(creatorId: creatorId)
} 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 updateCommunityPostFixed(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = UpdateCommunityPostFixedRequest(postId: selectedPostId, isFixed: !selectedPostIsPinned)
communityRepository.updateCommunityPostFixed(request: request)
.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.fetchFirstPage(creatorId: creatorId)
} 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 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) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getCommunity(creatorId: creatorId, page: requestPage, size: pageSize)
.sink { [weak self] result in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
self.isLoading = false
self.isLoadingNextPage = false
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.applySuccessState(data, isNextPage: isNextPage)
self.hasLoaded = true
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
self.isLoadingNextPage = false
}
.store(in: &subscription)
}
private func applySuccessState(_ data: CreatorChannelCommunityTabResponse, isNextPage: Bool) {
response = data
page = data.page
size = data.size
hasNext = data.hasNext
if isNextPage {
communityPosts.append(contentsOf: data.communityPosts)
} else {
communityPosts = data.communityPosts
}
}
private func applyFailureState() {
errorMessage = I18n.Common.commonError
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
struct CreatorChannelCommunityTabResponse: Decodable {
let communityPostCount: Int
let communityPosts: [CreatorChannelCommunityPostResponse]
let page: Int
let size: Int
let hasNext: Bool
struct CreatorChannelCommunityPostResponse: Decodable, Identifiable {
let postId: Int
let creatorId: Int
let creatorNickname: String
let creatorProfileUrl: String
let createdAtUtc: String
let content: String
let imageUrl: String?
let audioUrl: String?
let price: Int
let isCommentAvailable: Bool
let likeCount: Int
let commentCount: Int
let isPinned: Bool
let existOrdered: Bool
let isLiked: Bool?
var id: Int { postId }
func relativeTimeText(now: Date = Date()) -> String {
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
}
}
}
typealias CreatorChannelCommunityPostItem = CreatorChannelCommunityTabResponse.CreatorChannelCommunityPostResponse

View File

@@ -0,0 +1,41 @@
import Foundation
import Moya
enum CreatorChannelCommunityApi {
case getCommunity(creatorId: Int, page: Int, size: Int)
}
extension CreatorChannelCommunityApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getCommunity(let creatorId, _, _):
return "/api/v2/creator-channels/\(creatorId)/community"
}
}
var method: Moya.Method {
switch self {
case .getCommunity:
return .get
}
}
var task: Moya.Task {
switch self {
case .getCommunity(_, let page, let size):
return .requestParameters(
parameters: ["page": page, "size": size],
encoding: URLEncoding.queryString
)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class CreatorChannelCommunityRepository {
private let api = MoyaProvider<CreatorChannelCommunityApi>()
func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size))
}
}