diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index dddae622..4a7d7613 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -31,6 +31,9 @@ CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */; }; + CC0101132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; + CC0102132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; + CC0103132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */; }; CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */; }; CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */; }; CC0103092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */; }; @@ -1742,6 +1745,7 @@ CC0100102FF0100000A5BBA3 /* CreatorChannelTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTab.swift; sourceTree = ""; }; CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTitleBar.swift; sourceTree = ""; }; CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHeaderSection.swift; sourceTree = ""; }; + CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTabBar.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 = ""; }; @@ -2701,6 +2705,7 @@ children = ( CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */, CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */, + CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */, ); path = Components; sourceTree = ""; @@ -5597,6 +5602,7 @@ CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, + CC0101132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, @@ -6393,6 +6399,7 @@ CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, + CC0102132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, @@ -7205,6 +7212,7 @@ CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, + CC0103132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0103092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0103102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift index f0a6f1ec..fa2276aa 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift @@ -56,7 +56,6 @@ struct CreatorChannelHeaderSection: View { .padding(.bottom, SodaSpacing.s14) } .frame(width: screenSize().width, height: screenSize().width) - .ignoresSafeArea(.container, edges: .top) } private var talkButton: some View { diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTabBar.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTabBar.swift new file mode 100644 index 00000000..4132c3af --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTabBar.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct CreatorChannelTabBar: View { + @Binding var selectedTab: CreatorChannelTab + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(CreatorChannelTab.allCases, id: \.self) { tab in + Button { + selectedTab = tab + } label: { + VStack(spacing: 0) { + Text(tab.title) + .appFont(size: 16, weight: .medium) + .foregroundColor(selectedTab == tab ? Color.white : Color.gray500) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Rectangle() + .fill(selectedTab == tab ? Color.soda400 : Color.clear) + .frame(height: 3) + } + .frame(width: 110, height: 52) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .frame(height: 52) + .background(Color.black) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.gray900) + .frame(height: 1) + } + } +} + +struct CreatorChannelTabBar_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelTabBar(selectedTab: .constant(.home)) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift index f9842c07..5348dc5b 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift @@ -77,44 +77,46 @@ struct CreatorChannelTitleBar: View { } } -#Preview { - VStack(spacing: SodaSpacing.s16) { - CreatorChannelTitleBar( - isFollow: false, - isNotify: false, - backgroundProgress: 0, - onTapBack: {}, - onTapFollow: {}, - onTapUnfollow: {}, - onTapNotify: {}, - onTapUnnotify: {}, - onTapMore: {} - ) +struct CreatorChannelTitleBar_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: SodaSpacing.s16) { + CreatorChannelTitleBar( + isFollow: false, + isNotify: false, + backgroundProgress: 0, + onTapBack: {}, + onTapFollow: {}, + onTapUnfollow: {}, + onTapNotify: {}, + onTapUnnotify: {}, + onTapMore: {} + ) - CreatorChannelTitleBar( - isFollow: true, - isNotify: true, - backgroundProgress: 0, - onTapBack: {}, - onTapFollow: {}, - onTapUnfollow: {}, - onTapNotify: {}, - onTapUnnotify: {}, - onTapMore: {} - ) + CreatorChannelTitleBar( + isFollow: true, + isNotify: true, + backgroundProgress: 0, + onTapBack: {}, + onTapFollow: {}, + onTapUnfollow: {}, + onTapNotify: {}, + onTapUnnotify: {}, + onTapMore: {} + ) - CreatorChannelTitleBar( - isFollow: true, - isNotify: false, - backgroundProgress: 0, - onTapBack: {}, - onTapFollow: {}, - onTapUnfollow: {}, - onTapNotify: {}, - onTapUnnotify: {}, - onTapMore: {} - ) + CreatorChannelTitleBar( + isFollow: true, + isNotify: false, + backgroundProgress: 0, + onTapBack: {}, + onTapFollow: {}, + onTapUnfollow: {}, + onTapNotify: {}, + onTapUnnotify: {}, + onTapMore: {} + ) + } + .background(Color.gray) + .previewLayout(.sizeThatFits) } - .background(Color.gray) - .previewLayout(.sizeThatFits) } diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 50b169ad..8adba500 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -2,29 +2,31 @@ import SwiftUI struct CreatorChannelView: View { let creatorId: Int - + @StateObject private var viewModel = CreatorChannelViewModel() @Environment(\.dismiss) private var dismiss - + init(creatorId: Int, viewModel: CreatorChannelViewModel = CreatorChannelViewModel()) { self.creatorId = creatorId _viewModel = StateObject(wrappedValue: viewModel) } - + var body: some View { ZStack { Color.black.ignoresSafeArea() - + ZStack(alignment: .top) { - VStack(spacing: 0) { - headerSection - tabBar - selectedTabContent + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + headerSection + tabBar + selectedTabContent + } } - + titleBar } - + if viewModel.isLoading { LoadingView() } @@ -37,7 +39,7 @@ struct CreatorChannelView: View { } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) } - + private var titleBar: some View { CreatorChannelTitleBar( isFollow: viewModel.response?.creator.isFollow ?? false, @@ -61,95 +63,71 @@ struct CreatorChannelView: View { onTapMore: {} ) } - + @ViewBuilder private var headerSection: some View { if let creator = viewModel.response?.creator { CreatorChannelHeaderSection(creator: creator) } } - + private var tabBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - ForEach(CreatorChannelTab.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) + CreatorChannelTabBar(selectedTab: $viewModel.selectedTab) } - + @ViewBuilder private var selectedTabContent: some View { - if viewModel.selectedTab != .home || viewModel.isApiFailedPlaceholderVisible { - CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) - } else if let response = viewModel.response { - CreatorChannelHomeView(response: response) { tab in - viewModel.selectedTab = tab - } - } else { - CreatorChannelPlaceholderTabView(title: CreatorChannelTab.home.title) - } + CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) } } -#Preview { - let viewModel = CreatorChannelViewModel() - viewModel.response = CreatorChannelHomeResponse( - creator: CreatorChannelCreatorResponse( - creatorId: 1, - characterId: 10, - nickname: "Soda Creator", - profileImageUrl: "https://picsum.photos/600", - followerCount: 12345, - isAiChatAvailable: true, - isDmAvailable: false, - isFollow: true, - isNotify: true - ), - currentLive: nil, - latestAudioContent: nil, - channelDonations: [], - notices: [], - schedules: [], - audioContents: [], - series: [], - communities: [], - fanTalk: CreatorChannelFanTalkSummaryResponse(totalCount: 0, latestFanTalk: nil), - introduce: "", - activity: CreatorChannelActivityResponse( - debutDateUtc: nil, - dday: "D+1", - liveCount: 0, - liveDurationHours: 0, - liveContributorCount: 0, - audioContentCount: 0, - seriesCount: 0 - ), - sns: CreatorChannelSnsResponse( - instagramUrl: "", - fancimmUrl: "", - xurl: "", - youtubeUrl: "", - kakaoOpenChatUrl: "" +struct CreatorChannelView_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelView(creatorId: 1, viewModel: sampleViewModel) + } + + private static var sampleViewModel: CreatorChannelViewModel { + let viewModel = CreatorChannelViewModel() + viewModel.response = CreatorChannelHomeResponse( + creator: CreatorChannelCreatorResponse( + creatorId: 1, + characterId: 10, + nickname: "Soda Creator", + profileImageUrl: "https://picsum.photos/600", + followerCount: 12345, + isAiChatAvailable: true, + isDmAvailable: false, + isFollow: true, + isNotify: true + ), + currentLive: nil, + latestAudioContent: nil, + channelDonations: [], + notices: [], + schedules: [], + audioContents: [], + series: [], + communities: [], + fanTalk: CreatorChannelFanTalkSummaryResponse(totalCount: 0, latestFanTalk: nil), + introduce: "", + activity: CreatorChannelActivityResponse( + debutDateUtc: nil, + dday: "D+1", + liveCount: 0, + liveDurationHours: 0, + liveContributorCount: 0, + audioContentCount: 0, + seriesCount: 0 + ), + sns: CreatorChannelSnsResponse( + instagramUrl: "", + fancimmUrl: "", + xurl: "", + youtubeUrl: "", + kakaoOpenChatUrl: "" + ) ) - ) - viewModel.hasLoaded = true - - return CreatorChannelView(creatorId: 1, viewModel: viewModel) + viewModel.hasLoaded = true + return viewModel + } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift index a98ab519..f69e51a0 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelPlaceholderTabView.swift @@ -4,16 +4,40 @@ struct CreatorChannelPlaceholderTabView: View { let title: String var body: some View { - VStack(spacing: 12) { - Text(title) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) + VStack(spacing: 24) { + VStack(spacing: 12) { + Text(title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) - Text(I18n.CreatorChannelHome.placeholderMessage) - .font(.system(size: 14)) - .foregroundColor(Color.gray500) + Text(I18n.CreatorChannelHome.placeholderMessage) + .font(.system(size: 14)) + .foregroundColor(Color.gray500) + } + + #if DEBUG + VStack(spacing: 14) { + ForEach(1...12, id: \.self) { index in + VStack(alignment: .leading, spacing: 8) { + Text("DEBUG Dummy Content \(index)") + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + + Text("Sticky validation placeholder row") + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.gray900) + .cornerRadius(14) + } + } + .padding(.horizontal, 20) + #endif } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .top) .background(Color.black) } } diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index a2abfdc6..9eb8c9c3 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -272,7 +272,7 @@ - 실행 명령: `rg "struct CreatorChannelHeaderSection|CreatorChannelCreatorResponse|profileImageUrl|ignoresSafeArea\\(.*top|nickname|followerCount|DownsampledKFImage|KFImage" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` - 기대 결과: `CreatorChannelHomeResponse.creator` 기반 데이터 표시, 큰 배경 이미지의 top safe area 확장, 별도 프로필 이미지 미표시가 확인된다. -- [ ] **Task 3.3: horizontal tab-bar 생성** +- [x] **Task 3.3: horizontal tab-bar 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTabBar.swift` - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` diff --git a/docs/agent-guides/code-style.md b/docs/agent-guides/code-style.md index bd86b178..c2a01ddf 100644 --- a/docs/agent-guides/code-style.md +++ b/docs/agent-guides/code-style.md @@ -34,8 +34,8 @@ - 클로저 체인은 줄바꿈해 가독성을 유지한다. ## SwiftUI Preview 규칙 -- SwiftUI Preview는 `PreviewProvider` 대신 `#Preview { ... }` macro 형태를 우선 사용한다. -- `#Preview`는 별도 `#if DEBUG`로 감싸지 않는다. +- SwiftUI Preview는 `#Preview { ... }` macro 대신 `PreviewProvider` 형태를 우선 사용한다. +- 화면 단위 Preview에서 sample 상태가 필요하면 `PreviewProvider` 내부 helper로 구성한다. - Preview에서 이미지 URL 샘플이 필요하면 `https://picsum.photos/600`를 사용한다. ## 타입/상태 관리