diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 3030567d..8ed506b5 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -34,6 +34,9 @@ CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; + CC0101182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; + CC0102182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; + CC0103182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; }; @@ -1762,6 +1765,7 @@ CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = ""; }; CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationSection.swift; sourceTree = ""; }; CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelNoticeSection.swift; sourceTree = ""; }; + CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelScheduleSection.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 = ""; }; @@ -2685,6 +2689,7 @@ CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */, CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */, CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */, + CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */, ); path = Components; sourceTree = ""; @@ -5623,6 +5628,7 @@ CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, + CC0101182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -6424,6 +6430,7 @@ CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, + CC0102182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -7241,6 +7248,7 @@ CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */, CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, + CC0103182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index b3fe1063..e7fbf12f 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -48,7 +48,7 @@ struct CreatorChannelView: View { selectedTabContent } } - .ignoresSafeArea(.container, edges: .vertical) + .ignoresSafeArea(.container, edges: .top) if isTabBarSticky { tabBar @@ -156,8 +156,10 @@ struct CreatorChannelView: View { CreatorChannelHomeView( response: response, onSelectTab: selectTab, + onTapLive: showLiveDetail, onTapContent: showContentDetail, - onTapDonate: showDonationDialog + onTapDonate: showDonationDialog, + onTapSchedule: handleScheduleTap ) } else { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) @@ -182,9 +184,43 @@ struct CreatorChannelView: View { } private func showContentDetail(_ contentId: Int) { + guard contentId > 0 else { return } AppState.shared.setAppStep(step: .contentDetail(contentId: contentId)) } + private func showLiveDetail(_ roomId: Int) { + guard roomId > 0 else { return } + AppState.shared.setAppStep( + step: .liveDetail( + roomId: roomId, + onClickParticipant: {}, + onClickReservation: {}, + onClickStart: {}, + onClickCancel: {} + ) + ) + } + + private func showCommunity(_ creatorId: Int) { + guard creatorId > 0 else { return } + AppState.shared.setAppStep(step: .creatorCommunityAll(creatorId: creatorId)) + } + + private func handleScheduleTap(type: CreatorActivityType, targetId: Int) { + guard targetId > 0 else { return } + + switch type { + case .live, .liveReplay: + showLiveDetail(targetId) + case .audio: + showContentDetail(targetId) + case .community: + showCommunity(targetId) + case .unknown: + return + } + } + private func showDonationDialog() { guard let creatorId = viewModel.response?.creator.creatorId else { return } channelDonationViewModel.setCreatorId(creatorId, shouldFetch: false) diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift new file mode 100644 index 00000000..0d67d314 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift @@ -0,0 +1,239 @@ +import Foundation +import SwiftUI + +struct CreatorChannelScheduleSection: View { + let schedules: [CreatorChannelScheduleResponse] + let onTapSchedule: (CreatorActivityType, Int) -> Void + + init( + schedules: [CreatorChannelScheduleResponse], + onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in } + ) { + self.schedules = schedules + self.onTapSchedule = onTapSchedule + } + + var body: some View { + if !schedules.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.CreatorChannelHome.schedule) + + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + ForEach(Array(schedules.enumerated()), id: \.offset) { _, schedule in + CreatorChannelScheduleItemView(schedule: schedule) { + guard case .unknown = schedule.type else { + onTapSchedule(schedule.type, schedule.targetId) + return + } + } + } + } + .background(alignment: .leading) { + ScheduleTimelineLine() + .padding(.leading, 3) + .padding(.vertical, 52) + .opacity(schedules.count > 1 ? 1 : 0) + } + .padding(.horizontal, SodaSpacing.s14) + } + .padding(.top, SodaSpacing.s20) + } + } +} + +private struct CreatorChannelScheduleItemView: View { + let schedule: CreatorChannelScheduleResponse + let action: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + TimelineDotView(isHighlighted: schedule.isToday) + + Button(action: action) { + HStack(alignment: .center, spacing: SodaSpacing.s12) { + DateBoxView(schedule: schedule) + + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + Text(schedule.title) + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 0) + } + + HStack(alignment: .center, spacing: SodaSpacing.s8) { + ActivityTypeTagView(type: schedule.type) + + Spacer(minLength: 0) + + Text(schedule.timeText) + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + .multilineTextAlignment(.trailing) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SodaSpacing.s14) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } + } +} + +private struct ScheduleTimelineLine: View { + var body: some View { + Rectangle() + .fill(Color.gray700) + .frame(width: 2) + .frame(maxHeight: .infinity) + } +} + +private struct TimelineDotView: View { + let isHighlighted: Bool + + var body: some View { + Circle() + .fill(isHighlighted ? Color.soda400 : Color.gray500) + .frame(width: SodaSpacing.s8, height: SodaSpacing.s8) + .frame(width: SodaSpacing.s8) + } +} + +private struct DateBoxView: View { + let schedule: CreatorChannelScheduleResponse + + var body: some View { + VStack(alignment: .center, spacing: SodaSpacing.s4) { + if schedule.isToday { + Text(I18n.HomeFollowing.today) + .appFont(size: 22, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + } else { + Text(schedule.dayText) + .appFont(size: 22, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + + Text(schedule.weekdayText) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + } + .frame(width: 53, height: 53) + } +} + +private struct ActivityTypeTagView: View { + let type: CreatorActivityType + + var body: some View { + Text(type.scheduleTagTitle) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray100) + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .background(Color.gray700) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous)) + .lineLimit(1) + } +} + +private extension CreatorChannelScheduleResponse { + var scheduledDate: Date? { + DateParser.parse(scheduledAtUtc) + } + + var isToday: Bool { + guard let scheduledDate else { return false } + return Calendar.current.isDateInToday(scheduledDate) + } + + var dayText: String { + guard let scheduledDate else { return scheduledAtUtc } + return ScheduleDateFormatter.day.string(from: scheduledDate) + } + + var weekdayText: String { + guard let scheduledDate else { return "" } + return ScheduleDateFormatter.weekday.string(from: scheduledDate) + } + + var timeText: String { + guard let scheduledDate else { return scheduledAtUtc } + return ScheduleDateFormatter.time.string(from: scheduledDate) + } +} + +private extension CreatorActivityType { + var scheduleTagTitle: String { + switch self { + case .live: + return I18n.HomeFollowing.liveScheduleTag + case .liveReplay: + return I18n.HomeFollowing.liveReplayScheduleTag + case .audio: + return I18n.HomeFollowing.audioScheduleTag + case .community: + return I18n.HomeFollowing.communityScheduleTag + case .unknown(let rawValue): + return rawValue + } + } +} + +private enum ScheduleDateFormatter { + static let day: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "d" + return formatter + }() + + static let weekday: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "E" + return formatter + }() + + static let time: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a hh:mm" + return formatter + }() +} + +struct CreatorChannelScheduleSection_Previews: PreviewProvider { + static var previews: some View { + CreatorChannelScheduleSection( + schedules: [ + CreatorChannelScheduleResponse( + scheduledAtUtc: ISO8601DateFormatter().string(from: Date()), + title: "오늘 밤 라이브 방송", + type: .live, + targetId: 100 + ), + CreatorChannelScheduleResponse( + scheduledAtUtc: "2026-07-12T12:30:00Z", + title: "신규 오디오 콘텐츠 공개", + type: .audio, + targetId: 200 + ) + ] + ) + .padding(.vertical, SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift index 588306ee..7e91b29e 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift @@ -6,19 +6,22 @@ struct CreatorChannelHomeView: View { let onTapLive: (Int) -> Void let onTapContent: (Int) -> Void let onTapDonate: () -> Void + let onTapSchedule: (CreatorActivityType, Int) -> Void init( response: CreatorChannelHomeResponse, onSelectTab: @escaping (CreatorChannelTab) -> Void, onTapLive: @escaping (Int) -> Void = { _ in }, onTapContent: @escaping (Int) -> Void = { _ in }, - onTapDonate: @escaping () -> Void = {} + onTapDonate: @escaping () -> Void = {}, + onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in } ) { self.response = response self.onSelectTab = onSelectTab self.onTapLive = onTapLive self.onTapContent = onTapContent self.onTapDonate = onTapDonate + self.onTapSchedule = onTapSchedule } var body: some View { @@ -40,6 +43,11 @@ struct CreatorChannelHomeView: View { ) CreatorChannelNoticeSection(notices: response.notices) + + CreatorChannelScheduleSection( + schedules: response.schedules, + onTapSchedule: onTapSchedule + ) } .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black) diff --git a/docs/20260701_크리에이터_채널_홈/plan-task.md b/docs/20260701_크리에이터_채널_홈/plan-task.md index a5995f6a..12282318 100644 --- a/docs/20260701_크리에이터_채널_홈/plan-task.md +++ b/docs/20260701_크리에이터_채널_홈/plan-task.md @@ -417,7 +417,7 @@ ### Phase 9: 스케줄 섹션 -- [ ] **Task 9.1: 스케줄 섹션 구현** +- [x] **Task 9.1: 스케줄 섹션 구현** - 대상 파일: - 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift` - 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`