From 29f7f3c26cc885d2b102c6e2366ab3a521263bf2 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 1 Jul 2026 20:14:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=99=88?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 96 ++++++++++++ SodaLive/Sources/ContentView.swift | 2 +- SodaLive/Sources/I18n/I18n.swift | 36 +++++ .../CreatorChannelPlaceholderTabView.swift | 19 +++ .../Home/CreatorChannelHomeView.swift | 118 ++++++++++++++ .../Home/CreatorChannelHomeViewModel.swift | 147 ++++++++++++++++++ .../Models/CreatorChannelHomeResponse.swift | 140 +++++++++++++++++ .../Home/Models/CreatorChannelHomeTab.swift | 30 ++++ .../Repository/CreatorChannelHomeApi.swift | 38 +++++ .../CreatorChannelHomeRepository.swift | 13 ++ 10 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeViewModel.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeTab.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeApi.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 23c95316..f11b1454 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + CC0101012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */; }; + CC0102012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */; }; + CC0103012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */; }; + CC0101022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift */; }; + CC0102022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift */; }; + CC0103022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift */; }; + CC0101032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */; }; + CC0102032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */; }; + CC0103032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */; }; + CC0101042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */; }; + CC0102042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */; }; + CC0103042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */; }; + CC0101052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift */; }; + CC0102052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift */; }; + CC0103052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift */; }; + CC0101062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100062FF0100000A5BBA3 /* CreatorChannelHomeView.swift */; }; + CC0102062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100062FF0100000A5BBA3 /* CreatorChannelHomeView.swift */; }; + CC0103062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100062FF0100000A5BBA3 /* CreatorChannelHomeView.swift */; }; + CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */; }; + CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */; }; + CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -1702,6 +1723,13 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeResponse.swift; sourceTree = ""; }; + CC0100022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeTab.swift; sourceTree = ""; }; + CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeApi.swift; sourceTree = ""; }; + CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeRepository.swift; sourceTree = ""; }; + CC0100052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeViewModel.swift; sourceTree = ""; }; + CC0100062FF0100000A5BBA3 /* CreatorChannelHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeView.swift; sourceTree = ""; }; + CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelPlaceholderTabView.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = ""; }; @@ -2600,6 +2628,52 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + CC0100202FF0100000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */, + CC0100022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift */, + ); + path = Models; + sourceTree = ""; + }; + CC0100212FF0100000A5BBA3 /* Repository */ = { + isa = PBXGroup; + children = ( + CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */, + CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + CC0100222FF0100000A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */, + ); + path = Components; + sourceTree = ""; + }; + CC0100232FF0100000A5BBA3 /* Home */ = { + isa = PBXGroup; + children = ( + CC0100202FF0100000A5BBA3 /* Models */, + CC0100212FF0100000A5BBA3 /* Repository */, + CC0100222FF0100000A5BBA3 /* Components */, + CC0100062FF0100000A5BBA3 /* CreatorChannelHomeView.swift */, + CC0100052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift */, + ); + path = Home; + sourceTree = ""; + }; + CC0100242FF0100000A5BBA3 /* CreatorChannel */ = { + isa = PBXGroup; + children = ( + CC0100232FF0100000A5BBA3 /* Home */, + ); + path = CreatorChannel; + sourceTree = ""; + }; 038B15E425DE0639156ADBA9 /* Repository */ = { isa = PBXGroup; children = ( @@ -5119,6 +5193,7 @@ children = ( 976A594BED288A98C0EEA03C /* Component */, D979B1B9166521052893A897 /* Main */, + CC0100242FF0100000A5BBA3 /* CreatorChannel */, ); path = V2; sourceTree = ""; @@ -5483,6 +5558,13 @@ buildActionMask = 2147483647; files = ( 400EDE592B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */, + CC0101012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, + CC0101022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */, + CC0101032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, + CC0101042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */, + CC0101052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */, + CC0101062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */, + CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */, @@ -6269,6 +6351,13 @@ 38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */, 372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */, BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */, + CC0102012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, + CC0102022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */, + CC0102032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, + CC0102042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */, + CC0102052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */, + CC0102062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */, + CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, @@ -7071,6 +7160,13 @@ 7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */, 5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */, A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */, + CC0103012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, + CC0103022FF0100000A5BBA3 /* CreatorChannelHomeTab.swift in Sources */, + CC0103032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, + CC0103042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */, + CC0103052FF0100000A5BBA3 /* CreatorChannelHomeViewModel.swift in Sources */, + CC0103062FF0100000A5BBA3 /* CreatorChannelHomeView.swift in Sources */, + CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 8c61eb77..bd7799ea 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -169,7 +169,7 @@ struct AppStepLayerView: View { LiveReservationCompleteView(reservationCompleteData: response) case .creatorDetail(let userId): - UserProfileView(userId: userId) + CreatorChannelHomeView(creatorId: userId) case .followerList(let userId): FollowerListView(userId: userId) diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 1d27ee2b..28013b0b 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3162,6 +3162,42 @@ If you block this user, the following features will be restricted. } } + enum CreatorChannelHome { + static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") } + static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") } + static var donation: String { pick(ko: "채널 후원", en: "Channel donations", ja: "チャンネル応援") } + static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") } + static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") } + static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } + static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") } + static var community: String { pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") } + static var fanTalk: String { pick(ko: "팬Talk", en: "Fan Talk", ja: "ファンTalk") } + static var introduce: String { pick(ko: "소개", en: "Introduction", ja: "紹介") } + static var activity: String { pick(ko: "활동", en: "Activity", ja: "活動") } + static var sns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") } + static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") } + static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") } + static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") } + static var viewAll: String { pick(ko: "전체보기", en: "View all", ja: "すべて見る") } + static var debut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") } + static var liveCount: String { pick(ko: "라이브 총 진행 수", en: "Total live count", ja: "ライブ総配信数") } + static var liveDurationHours: String { pick(ko: "라이브 누적 진행 시간", en: "Total live hours", ja: "ライブ累計配信時間") } + static var liveContributorCount: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累計参加者") } + static var audioContentCount: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } + static var seriesCount: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") } + static var placeholderMessage: String { + pick( + ko: "준비 중입니다.", + en: "Coming soon.", + ja: "準備中です。" + ) + } + + static func followerCount(_ count: String) -> String { + pick(ko: "팔로워 \(count)명", en: "\(count) followers", ja: "フォロワー\(count)人") + } + } + enum HomeRecommendation { static var activityLive: String { pick(ko: "라이브", en: "Live", ja: "ライブ") diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift new file mode 100644 index 00000000..a98ab519 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CreatorChannelPlaceholderTabView: View { + let title: String + + var body: some View { + VStack(spacing: 12) { + Text(title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + + Text(I18n.CreatorChannelHome.placeholderMessage) + .font(.system(size: 14)) + .foregroundColor(Color.gray500) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift new file mode 100644 index 00000000..b50ecf7a --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct CreatorChannelHomeView: View { + let creatorId: Int + + @StateObject private var viewModel = CreatorChannelHomeViewModel() + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 0) { + titleBar + tabBar + + if viewModel.selectedTab != .home || viewModel.isApiFailedPlaceholderVisible { + CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) + } else { + homeContent + } + } + + if viewModel.isLoading { + LoadingView() + } + } + .navigationBarBackButtonHidden(true) + .onAppear { + if viewModel.hasLoaded == false { + viewModel.fetchHome(creatorId: creatorId) + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) + } + + private var titleBar: some View { + HStack(spacing: 12) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .frame(width: 44, height: 44) + } + + Spacer() + + if let creator = viewModel.response?.creator { + Button { + if creator.isFollow { + viewModel.unfollow(creatorId: creator.creatorId) + } else { + viewModel.follow(creatorId: creator.creatorId, notify: true) + } + } label: { + Text(creator.isFollow ? I18n.CreatorChannelHome.following : I18n.CreatorChannelHome.follow) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(creator.isFollow ? .white : .black) + .padding(.horizontal, 12) + .frame(height: 30) + .background(creator.isFollow ? Color.gray33 : Color.soda400) + .clipShape(Capsule()) + } + } + } + .frame(height: 56) + .padding(.horizontal, 8) + .background(Color.black) + } + + private var tabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(CreatorChannelHomeTab.allCases, id: \.self) { tab in + Button { + viewModel.selectedTab = tab + } label: { + VStack(spacing: 8) { + Text(tab.title) + .font(.system(size: 15, weight: viewModel.selectedTab == tab ? .semibold : .regular)) + .foregroundColor(viewModel.selectedTab == tab ? .white : Color.gray500) + + Rectangle() + .fill(viewModel.selectedTab == tab ? Color.soda400 : Color.clear) + .frame(height: 2) + } + } + } + } + .padding(.horizontal, 20) + } + .frame(height: 48) + .background(Color.black) + } + + private var homeContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let creator = viewModel.response?.creator { + Text(creator.nickname) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Text(I18n.CreatorChannelHome.followerCount(creator.followerCount.comma())) + .font(.system(size: 14)) + .foregroundColor(Color.gray500) + } else { + CreatorChannelPlaceholderTabView(title: CreatorChannelHomeTab.home.title) + .frame(minHeight: 240) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .background(Color.black) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeViewModel.swift new file mode 100644 index 00000000..72fb6114 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeViewModel.swift @@ -0,0 +1,147 @@ +import Foundation +import Combine + +final class CreatorChannelHomeViewModel: ObservableObject { + private let repository = CreatorChannelHomeRepository() + private let userRepository = UserRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var response: CreatorChannelHomeResponse? + @Published var hasLoaded = false + @Published var selectedTab: CreatorChannelHomeTab = .home + @Published var isApiFailedPlaceholderVisible = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + func fetchHome(creatorId: Int) { + isLoading = true + isApiFailedPlaceholderVisible = false + + repository.getHome(creatorId: creatorId) + .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.applyApiFailedPlaceholderState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: response.data) + + if let data = decoded.data, decoded.success { + self.response = data + self.selectedTab = .home + self.isApiFailedPlaceholderVisible = false + } else { + if let message = decoded.message { + ERROR_LOG(message) + } + self.applyApiFailedPlaceholderState() + } + } catch { + ERROR_LOG(error.localizedDescription) + self.applyApiFailedPlaceholderState() + } + + self.hasLoaded = true + self.isLoading = false + } + .store(in: &subscription) + } + + func follow(creatorId: Int, notify: Bool = true) { + isLoading = true + + userRepository.creatorFollow(creatorId: creatorId, follow: true, notify: notify) + .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.isLoading = false + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data) + + if decoded.success { + self.updateCreatorFollowState(isFollow: true, isNotify: notify) + } 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 + } + .store(in: &subscription) + } + + func unfollow(creatorId: Int) { + isLoading = true + + userRepository.creatorUnFollow(creatorId: creatorId) + .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.isLoading = false + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data) + + if decoded.success { + self.updateCreatorFollowState(isFollow: false, isNotify: false) + } 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 + } + .store(in: &subscription) + } + + private func applyApiFailedPlaceholderState() { + response = nil + isApiFailedPlaceholderVisible = true + hasLoaded = true + isLoading = false + } + + private func updateCreatorFollowState(isFollow: Bool, isNotify: Bool) { + response?.creator.isFollow = isFollow + response?.creator.isNotify = isNotify + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift new file mode 100644 index 00000000..03bddd0b --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift @@ -0,0 +1,140 @@ +import Foundation + +struct CreatorChannelHomeResponse: Decodable { + var creator: CreatorChannelCreatorResponse + let currentLive: CreatorChannelLiveResponse? + let latestAudioContent: CreatorChannelAudioContentResponse? + let channelDonations: [CreatorChannelDonationResponse] + let notices: [CreatorChannelCommunityPostResponse] + let schedules: [CreatorChannelScheduleResponse] + let audioContents: [CreatorChannelAudioContentResponse] + let series: [CreatorChannelSeriesResponse] + let communities: [CreatorChannelCommunityPostResponse] + let fanTalk: CreatorChannelFanTalkSummaryResponse + let introduce: String + let activity: CreatorChannelActivityResponse + let sns: CreatorChannelSnsResponse +} + +struct CreatorChannelCreatorResponse: Decodable, Identifiable { + let creatorId: Int + let nickname: String + let profileImageUrl: String? + let followerCount: Int + let characterId: Int? + var isFollow: Bool + var isNotify: Bool + let isAiChatAvailable: Bool + let isDmAvailable: Bool + + var id: Int { creatorId } +} + +struct CreatorChannelLiveResponse: Decodable, Identifiable { + let liveId: Int + let title: String + let coverImageUrl: String? + let beginDateTimeUtc: String + let price: Int + let isAdult: Bool + + var id: Int { liveId } +} + +struct CreatorChannelAudioContentResponse: Decodable, Identifiable { + let audioContentId: Int + let title: String + let duration: Int + let imageUrl: String? + let price: Int + let isAdult: Bool + let isPointAvailable: Bool + let isFirstContent: Bool + let seriesName: String? + let isOriginalSeries: Bool + + var id: Int { audioContentId } +} + +struct CreatorChannelDonationResponse: Decodable, Identifiable { + let donationId: Int + let nickname: String + let profileImageUrl: String? + let can: Int + let message: String + let createdAtUtc: String + + var id: Int { donationId } +} + +struct CreatorChannelScheduleResponse: Decodable, Identifiable { + let scheduleId: String + let scheduledAtUtc: String + let title: String + let type: CreatorActivityType + let targetId: Int + + var id: String { scheduleId } +} + +struct CreatorChannelSeriesResponse: Decodable, Identifiable { + let seriesId: Int + let title: String + let coverImageUrl: String? + let numberOfContent: Int + let isNew: Bool + let isOriginal: Bool + + var id: Int { seriesId } +} + +struct CreatorChannelCommunityPostResponse: Decodable, Identifiable { + let postId: Int + let creatorId: Int + let creatorNickname: String + let creatorProfileImageUrl: String? + let content: String + let imageUrl: String? + let audioUrl: String? + let price: Int + let existOrdered: Bool + let likeCount: Int + let commentCount: Int + let createdAtUtc: String + + var id: Int { postId } +} + +struct CreatorChannelFanTalkSummaryResponse: Decodable { + let totalCount: Int + let latestFanTalk: CreatorChannelFanTalkResponse? +} + +struct CreatorChannelFanTalkResponse: Decodable, Identifiable { + let fanTalkId: Int + let nickname: String + let profileImageUrl: String? + let content: String + let languageCode: String + let createdAtUtc: String + + var id: Int { fanTalkId } +} + +struct CreatorChannelActivityResponse: Decodable { + let debutDateUtc: String + let dDay: Int + let liveCount: Int + let liveDurationHours: Int + let liveContributorCount: Int + let audioContentCount: Int + let seriesCount: Int +} + +struct CreatorChannelSnsResponse: Decodable { + let instagramUrl: String? + let youtubeUrl: String? + let xUrl: String? + let kakaoOpenChatUrl: String? + let fancimmUrl: String? +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeTab.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeTab.swift new file mode 100644 index 00000000..17f01fbc --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeTab.swift @@ -0,0 +1,30 @@ +import Foundation + +enum CreatorChannelHomeTab: CaseIterable, Hashable { + case home + case live + case audio + case series + case community + case fanTalk + case donation + + var title: String { + switch self { + case .home: + return "홈" + case .live: + return "라이브" + case .audio: + return "오디오" + case .series: + return "시리즈" + case .community: + return "커뮤니티" + case .fanTalk: + return "팬Talk" + case .donation: + return "후원" + } + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeApi.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeApi.swift new file mode 100644 index 00000000..622f3e7b --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeApi.swift @@ -0,0 +1,38 @@ +import Foundation + +import Moya + +enum CreatorChannelHomeApi { + case getHome(creatorId: Int) +} + +extension CreatorChannelHomeApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getHome(let creatorId): + return "/api/v2/creator-channels/\(creatorId)/home" + } + } + + var method: Moya.Method { + switch self { + case .getHome: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getHome: + return .requestPlain + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift new file mode 100644 index 00000000..1835dff5 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift @@ -0,0 +1,13 @@ +import Foundation +import Combine + +import CombineMoya +import Moya + +final class CreatorChannelHomeRepository { + private let api = MoyaProvider() + + func getHome(creatorId: Int) -> AnyPublisher { + return api.requestPublisher(.getHome(creatorId: creatorId)) + } +}