diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 62851302..dddae622 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */; }; + 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 */; }; 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 */; }; @@ -1738,6 +1741,7 @@ CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelViewModel.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -2696,6 +2700,7 @@ isa = PBXGroup; children = ( CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */, + CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */, ); path = Components; sourceTree = ""; @@ -5591,6 +5596,7 @@ CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, + CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, @@ -6386,6 +6392,7 @@ CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, + CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, @@ -7197,6 +7204,7 @@ CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */, CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, + CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.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 new file mode 100644 index 00000000..f0a6f1ec --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift @@ -0,0 +1,81 @@ +import SwiftUI +import Kingfisher + +struct CreatorChannelHeaderSection: View { + let creator: CreatorChannelCreatorResponse + + var body: some View { + ZStack(alignment: .bottom) { + KFImage(URL(string: creator.profileImageUrl)) + .placeholder { Color.gray.opacity(0.2) } + .retry(maxCount: 2, interval: .seconds(1)) + .cancelOnDisappear(true) + .downsampled(to: CGSize(width: screenSize().width, height: screenSize().width)) + .resizable() + .scaledToFill() + .frame(width: screenSize().width, height: screenSize().width) + .clipped() + + VStack(spacing: 0) { + LinearGradient( + colors: [Color.black.opacity(0.3), Color.black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 120) + + Spacer(minLength: 0) + + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.black.opacity(0), location: 0), + .init(color: Color.black.opacity(0), location: 0.35), + .init(color: Color.black.opacity(0.85), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 180) + } + + VStack(alignment: .center, spacing: SodaSpacing.s8) { + Text(I18n.CreatorChannelHome.followerCount(creator.followerCount.comma())) + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Text(creator.nickname) + .appFont(size: 32, weight: .bold) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + talkButton + } + .frame(maxWidth: .infinity) + .padding(.horizontal, SodaSpacing.s24) + .padding(.bottom, SodaSpacing.s14) + } + .frame(width: screenSize().width, height: screenSize().width) + .ignoresSafeArea(.container, edges: .top) + } + + private var talkButton: some View { + Button(action: {}) { + HStack(spacing: SodaSpacing.s6) { + Image("ic_new_talk") + .resizable() + .frame(width: 20, height: 20) + + Text(I18n.CreatorChannelHome.talk) + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + } + .padding(SodaSpacing.s12) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift index 0694560f..f9842c07 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift @@ -57,7 +57,8 @@ struct CreatorChannelTitleBar: View { .foregroundColor(Color.white) } } - .padding(SodaSpacing.s8) + .frame(width: isFollow ? 36 : nil, height: 36) + .padding(.horizontal, isFollow ? 0 : SodaSpacing.s12) .background(isFollow ? Color.white : Color.clear) .clipShape(Capsule()) } @@ -67,54 +68,53 @@ struct CreatorChannelTitleBar: View { private var notifyButton: some View { Button(action: isNotify ? onTapUnnotify : onTapNotify) { - Image(isNotify ? "ic_bar_bell_fill" : "ic_bar_bell") + Image(isNotify ? "ic_bar_bell_colored" : "ic_bar_bell") .resizable() .frame(width: 24, height: 24) + .frame(width: 36, height: 36) } .buttonStyle(.plain) } } -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: {} - ) +#Preview { + VStack(spacing: SodaSpacing.s16) { + CreatorChannelTitleBar( + isFollow: false, + isNotify: false, + backgroundProgress: 0, + onTapBack: {}, + onTapFollow: {}, + onTapUnfollow: {}, + onTapNotify: {}, + onTapUnnotify: {}, + onTapMore: {} + ) - CreatorChannelTitleBar( - isFollow: true, - isNotify: true, - backgroundProgress: 1, - 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: 1, - onTapBack: {}, - onTapFollow: {}, - onTapUnfollow: {}, - onTapNotify: {}, - onTapUnnotify: {}, - onTapMore: {} - ) - } - .background(Color.gray) - .previewLayout(.sizeThatFits) + CreatorChannelTitleBar( + isFollow: true, + isNotify: false, + backgroundProgress: 0, + onTapBack: {}, + onTapFollow: {}, + onTapUnfollow: {}, + onTapNotify: {}, + onTapUnnotify: {}, + onTapMore: {} + ) } + .background(Color.gray) + .previewLayout(.sizeThatFits) } diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 1d4dc23a..50b169ad 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -6,14 +6,23 @@ struct CreatorChannelView: View { @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() - VStack(spacing: 0) { + ZStack(alignment: .top) { + VStack(spacing: 0) { + headerSection + tabBar + selectedTabContent + } + titleBar - tabBar - selectedTabContent } if viewModel.isLoading { @@ -33,7 +42,7 @@ struct CreatorChannelView: View { CreatorChannelTitleBar( isFollow: viewModel.response?.creator.isFollow ?? false, isNotify: viewModel.response?.creator.isNotify ?? false, - backgroundProgress: 1, + backgroundProgress: 0, onTapBack: { dismiss() }, @@ -53,6 +62,13 @@ struct CreatorChannelView: View { ) } + @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) { @@ -91,3 +107,49 @@ struct CreatorChannelView: View { } } } + +#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: "" + ) + ) + viewModel.hasLoaded = true + + return CreatorChannelView(creatorId: 1, viewModel: viewModel) +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift index d7eedfda..17fb2e7e 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift @@ -6,17 +6,8 @@ struct CreatorChannelHomeView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(response.creator.nickname) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) - - Text(I18n.CreatorChannelHome.followerCount(response.creator.followerCount.comma())) - .font(.system(size: 14)) - .foregroundColor(Color.gray500) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(20) + Color.clear + .frame(height: 1) } .background(Color.black) } diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index 5db0e95a..a2abfdc6 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -257,7 +257,7 @@ - 실행 명령: `rg "struct CreatorChannelTitleBar|isFollow|isNotify|backgroundProgress|onTapBack|onTapFollow|onTapUnfollow|onTapNotify|onTapUnnotify|onTapMore|ic_new_bar_back|ic_new_follow|ic_new_following|ic_new_more|ic_bar_bell|ic_bar_bell_fill|팔로우" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift` - 기대 결과: Figma 3개 title bar 상태를 표현하는 조건 분기, 지정된 6개 icon asset 사용, 팔로우/팔로우 취소/알림 설정/알림 설정 취소 tap closure가 확인된다. -- [ ] **Task 3.2: 크리에이터 이미지/프로필 헤더 생성** +- [x] **Task 3.2: 크리에이터 이미지/프로필 헤더 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift` - 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift` @@ -617,5 +617,6 @@ - 2026-07-02: 이미 완료된 Phase 1~2 기록은 보존하고, 후속 구조 정리를 위해 Task 2.4 `공통 CreatorChannel shell로 구조 정리`를 추가했다. - 2026-07-02: 사용자 확인 사항을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_fill`)을 Task 3.1 및 최종 회귀 검색 기준에 추가했다. - 2026-07-02: 기존 `UserProfile` 구현을 확인해 title bar 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하고, `ic_bar_bell`/`ic_bar_bell_fill` 터치 시 각각 `notify: true`/`notify: false` 호출이 일어나도록 계획에 반영했다. +- 2026-07-02: Task 3.2 구현을 완료하고 `CreatorChannelHeaderSection`을 공통 shell에 연결했다. `rg "struct CreatorChannelHeaderSection|CreatorChannelCreatorResponse|profileImageUrl|ignoresSafeArea\\(.*top|nickname|followerCount|DownsampledKFImage|KFImage" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다. - 2026-07-02: Task 3.1 title bar 컴포넌트를 `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift`로 생성하고 `CreatorChannelView`의 inline title bar를 교체했다. `rg "struct CreatorChannelTitleBar|isFollow|isNotify|backgroundProgress|onTapBack|onTapFollow|onTapUnfollow|onTapNotify|onTapUnnotify|onTapMore|ic_new_bar_back|ic_new_follow|ic_new_following|ic_new_more|ic_bar_bell|ic_bar_bell_fill|팔로우" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift`와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다. - 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 Task 3.2/3.4/3.5와 최종 검증 기준을 갱신했다. diff --git a/docs/agent-guides/code-style.md b/docs/agent-guides/code-style.md index 3fdf2f2e..bd86b178 100644 --- a/docs/agent-guides/code-style.md +++ b/docs/agent-guides/code-style.md @@ -34,7 +34,9 @@ - 클로저 체인은 줄바꿈해 가독성을 유지한다. ## SwiftUI Preview 규칙 -- Preview에서 이미지 URL 샘플이 필요하면 `https://placehold.co/500`를 사용한다. +- SwiftUI Preview는 `PreviewProvider` 대신 `#Preview { ... }` macro 형태를 우선 사용한다. +- `#Preview`는 별도 `#if DEBUG`로 감싸지 않는다. +- Preview에서 이미지 URL 샘플이 필요하면 `https://picsum.photos/600`를 사용한다. ## 타입/상태 관리 - ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.