feat(home): 팔로잉 스케줄 섹션을 추가한다

This commit is contained in:
Yu Sung
2026-07-01 13:38:43 +09:00
parent 772af3dfe3
commit e3dd508528
6 changed files with 325 additions and 6 deletions

View File

@@ -267,6 +267,9 @@
401C104A2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */; };
401C104B2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */; };
401C104C2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */; };
401C104D2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */; };
401C104E2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */; };
401C104F2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */; };
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; };
@@ -1808,6 +1811,7 @@
401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingCreatorSection.swift; sourceTree = "<group>"; };
401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingLiveSection.swift; sourceTree = "<group>"; };
401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingChatSection.swift; sourceTree = "<group>"; };
401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingScheduleSection.swift; sourceTree = "<group>"; };
40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; };
40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; };
@@ -2822,6 +2826,7 @@
401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */,
401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */,
401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */,
401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */,
);
path = Components;
sourceTree = "<group>";
@@ -5493,6 +5498,7 @@
401C10412FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */,
401C10452FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */,
401C104A2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */,
401C104D2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */,
409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
@@ -6260,6 +6266,7 @@
401C10422FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */,
401C10462FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */,
401C104B2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */,
401C104E2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */,
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */,
CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */,
A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */,
@@ -7057,6 +7064,7 @@
401C10432FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */,
401C10472FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */,
401C104C2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */,
401C104F2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */,
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */,
33BA3AB731849284E62AE04B /* MainTab.swift in Sources */,
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */,

View File

@@ -0,0 +1,274 @@
import Foundation
import SwiftUI
struct MainHomeFollowingScheduleSection: View {
let monthlySchedules: [FollowingScheduleResponse]
let onTapSchedule: (CreatorActivityType, Int) -> Void
init(
monthlySchedules: [FollowingScheduleResponse],
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }
) {
self.monthlySchedules = monthlySchedules
self.onTapSchedule = onTapSchedule
}
var body: some View {
if !monthlySchedules.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
SectionTitle(title: I18n.HomeFollowing.monthlyScheduleTitle)
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
ForEach(monthlySchedules) { schedule in
MainHomeFollowingScheduleItemView(schedule: schedule) {
onTapSchedule(schedule.type, schedule.targetId)
}
}
}
.background(alignment: .leading) {
ScheduleTimelineLine()
.padding(.leading, 3)
.padding(.vertical, 52)
.opacity(monthlySchedules.count > 1 ? 1 : 0)
}
.padding(.horizontal, SodaSpacing.s14)
}
}
}
}
private struct MainHomeFollowingScheduleItemView: View {
let schedule: FollowingScheduleResponse
let action: () -> Void
var body: some View {
HStack(alignment: .center, spacing: SodaSpacing.s14) {
TimelineDotView(isHighlighted: schedule.isToday || schedule.isOnAir)
Button(action: action) {
HStack(alignment: .center, spacing: SodaSpacing.s12) {
DateBoxView(schedule: schedule)
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
creatorInfo
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.statusText)
.appFont(size: 16, weight: .regular)
.foregroundColor(schedule.isOnAir ? Color.soda400 : Color.gray500)
.lineLimit(1)
.multilineTextAlignment(.trailing)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(SodaSpacing.s14)
.padding(.leading, schedule.isOnAir ? SodaSpacing.s8 : 0)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(alignment: .leading) {
if schedule.isOnAir {
Color.soda400
.frame(width: SodaSpacing.s4)
}
}
}
.buttonStyle(.plain)
}
}
private var creatorInfo: some View {
HStack(alignment: .center, spacing: SodaSpacing.s6) {
DownsampledKFImage(url: URL(string: schedule.creatorProfileImageUrl), size: CGSize(width: 20, height: 20))
.clipShape(Circle())
.frame(width: 20, height: 20)
Text(schedule.creatorNickname)
.appFont(size: 12, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
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: FollowingScheduleResponse
var body: some View {
VStack(alignment: .center, spacing: SodaSpacing.s4) {
if schedule.isToday {
Text("오늘")
.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 FollowingScheduleResponse {
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 statusText: String {
if isOnAir {
return "On Air"
}
guard let scheduledDate else { return scheduledAtUtc }
return ScheduleDateFormatter.time.string(from: scheduledDate)
}
}
private extension CreatorActivityType {
var scheduleTagTitle: String {
switch self {
case .live:
return "라이브"
case .liveReplay:
return "다시보기"
case .audio:
return "오디오"
case .community:
return "커뮤니티"
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 MainHomeFollowingScheduleSection_Previews: PreviewProvider {
private static let previewImageUrl = "https://picsum.photos/500/500"
static var previews: some View {
MainHomeFollowingScheduleSection(
monthlySchedules: [
FollowingScheduleResponse(
scheduleId: "today-on-air",
creatorId: 1,
creatorProfileImageUrl: previewImageUrl,
creatorNickname: "크리에이터",
title: "오늘 밤 같이 이야기해요",
type: .live,
targetId: 100,
scheduledAtUtc: ISO8601DateFormatter().string(from: Date()),
isOnAir: false
),
FollowingScheduleResponse(
scheduleId: "normal-schedule",
creatorId: 2,
creatorProfileImageUrl: previewImageUrl,
creatorNickname: "소다",
title: "신규 오디오 콘텐츠 공개",
type: .audio,
targetId: 200,
scheduledAtUtc: "2026-07-12T12:30:00Z",
isOnAir: false
)
]
)
.padding(.vertical, SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -6,6 +6,7 @@ struct MainHomeFollowingView: View {
let onTapFollowingAll: () -> Void
let onTapChatTab: () -> Void
let onTapChatRoom: (Int) -> Void
let onTapSchedule: (CreatorActivityType, Int) -> Void
@StateObject private var viewModel = MainHomeFollowingViewModel()
@@ -14,13 +15,15 @@ struct MainHomeFollowingView: View {
onTapCreator: @escaping (Int) -> Void = { _ in },
onTapFollowingAll: @escaping () -> Void = {},
onTapChatTab: @escaping () -> Void = {},
onTapChatRoom: @escaping (Int) -> Void = { _ in }
onTapChatRoom: @escaping (Int) -> Void = { _ in },
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }
) {
self.onTapLive = onTapLive
self.onTapCreator = onTapCreator
self.onTapFollowingAll = onTapFollowingAll
self.onTapChatTab = onTapChatTab
self.onTapChatRoom = onTapChatRoom
self.onTapSchedule = onTapSchedule
}
var body: some View {
@@ -44,6 +47,13 @@ struct MainHomeFollowingView: View {
)
}
if let monthlySchedules = viewModel.response?.monthlySchedules, !monthlySchedules.isEmpty {
MainHomeFollowingScheduleSection(
monthlySchedules: monthlySchedules,
onTapSchedule: onTapSchedule
)
}
MainPlaceholderTabView(title: "팔로잉")
}
.onAppear {

View File

@@ -15,6 +15,7 @@ struct MainHomeView: View {
let onTapFollowingAll: () -> Void
let onTapChatTab: () -> Void
let onTapChatRoom: (Int) -> Void
let onTapFollowingSchedule: (CreatorActivityType, Int) -> Void
@State private var selectedTab: MainHomeTab = .recommendation
@@ -79,7 +80,8 @@ struct MainHomeView: View {
onTapCreator: onTapCreator,
onTapFollowingAll: onTapFollowingAll,
onTapChatTab: onTapChatTab,
onTapChatRoom: onTapChatRoom
onTapChatRoom: onTapChatRoom,
onTapSchedule: onTapFollowingSchedule
)
}
}
@@ -118,7 +120,8 @@ struct MainHomeView_Previews: PreviewProvider {
onSelectFollowingTab: { true },
onTapFollowingAll: {},
onTapChatTab: {},
onTapChatRoom: { _ in }
onTapChatRoom: { _ in },
onTapFollowingSchedule: { _, _ in }
)
}
}

View File

@@ -167,7 +167,8 @@ struct MainView: View {
onSelectFollowingTab: handleFollowingTabSelection,
onTapFollowingAll: handleFollowingAllTap,
onTapChatTab: handleFollowingChatTabTap,
onTapChatRoom: handleFollowingChatRoomTap
onTapChatRoom: handleFollowingChatRoomTap,
onTapFollowingSchedule: handleFollowingScheduleTap
)
case .content:
MainPlaceholderTabView(title: MainTab.content.title)
@@ -501,6 +502,21 @@ struct MainView: View {
appState.setAppStep(step: .chatRoom(id: roomId))
}
private func handleFollowingScheduleTap(type: CreatorActivityType, targetId: Int) {
guard targetId > 0 else { return }
switch type {
case .live, .liveReplay:
handleRecommendationLiveTap(roomId: targetId)
case .audio:
handleRecommendationContentTap(contentId: targetId)
case .community:
handleRecommendationCommunityTap(creatorId: targetId)
case .unknown:
return
}
}
private func handleFollowingTabSelection() -> Bool {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {

View File

@@ -284,7 +284,7 @@
### Phase 7: 이달의 스케줄 섹션
- [ ] **Task 7.1: 이달의 스케줄 섹션 생성**
- [x] **Task 7.1: 이달의 스케줄 섹션 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift`
- 작업 내용:
@@ -300,7 +300,7 @@
- 실행 명령: `rg "struct MainHomeFollowingScheduleSection|monthlyScheduleTitle|FollowingScheduleResponse|scheduledAtUtc|isOnAir|Color.soda400|CreatorActivityType|onTapSchedule|오늘" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift`
- 기대 결과: 스케줄 카드 표시, On Air 강조, schedule tap callback이 확인된다.
- [ ] **Task 7.2: 스케줄 상세 진입 연결**
- [x] **Task 7.2: 스케줄 상세 진입 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
@@ -527,3 +527,11 @@
- 2026-07-01: `rg "MainHomeFollowingChatSection.swift|MainHomeFollowingChatSection.swift in Sources" SodaLive.xcodeproj/project.pbxproj`로 신규 Swift 파일의 Xcode 프로젝트 등록을 확인했다.
- 2026-07-01: `if rg "localhost:3845|unread" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift; then exit 1; else true; fi`를 실행해 Figma localhost asset과 별도 unread 표시가 들어가지 않았음을 확인했다.
- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 실행해 Phase 6 구현 후 빌드 성공을 확인했다. 기존 duplicate build file/Crashlytics dSYM/AppIntents warning은 남아 있으나 컴파일 오류는 없었다.
- 2026-07-01: Figma `24:5703`, `24:5706`, `24:5707`을 확인해 Phase 7 이달의 스케줄 섹션을 구현했다. 카드 `Color.gray900`, 14pt radius/padding, 53x53 날짜 영역, 20pt 프로필, 16pt 제목/상태, gray700 활동 tag를 반영했고, 오늘/On Air dot 및 On Air 상태/좌측 accent는 `Color.soda400`, 일반 schedule dot/line/status는 gray 계열로 분리했다.
- 2026-07-01: `MainHomeFollowingView -> MainHomeView -> MainView.handleFollowingScheduleTap(type:targetId:)` 경로로 스케줄 tap callback을 연결했다. `.live`/`.liveReplay`은 기존 live 입장, `.audio`는 콘텐츠 상세, `.community`는 기존 정책에 따라 `creatorCommunityAll(creatorId:)`, `.unknown`은 no-op 처리했다.
- 2026-07-01: `SodaLive.xcodeproj/project.pbxproj``MainHomeFollowingScheduleSection.swift` 파일 참조와 Sources build file을 추가해 Xcode navigator와 target compile sources에서 확인 가능하도록 등록했다.
- 2026-07-01: `rg "MainHomeFollowingScheduleSection.swift|MainHomeFollowingScheduleSection.swift in Sources|onTapFollowingSchedule|onTapSchedule|monthlySchedules|CreatorActivityType|contentDetail|creatorCommunityAll|enterLiveRoom|On Air|a hh:mm|오늘|Color.soda400|Color.gray500|localhost:3845" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive.xcodeproj/project.pbxproj`로 Phase 7 UI, 라우팅, 프로젝트 등록, Figma localhost asset 미사용을 확인했다.
- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`는 기존 DerivedData `build.db` lock으로 실패했으나, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase7" build`로 재실행해 빌드 성공을 확인했다. 기존 duplicate build file/run script/AppIntents/dependency scan warning은 남아 있으나 컴파일 오류는 없었다.
- 2026-07-01: 사용자 피드백에 따라 Figma `24:5703`의 왼쪽 스케줄 timeline을 재확인했다. 기존 item별 `TimelineGuideView`는 카드 사이 gap에서 line이 끊겨 보이고 dot 위치가 Figma보다 카드에 가까웠으므로, list 전체 overlay의 `ScheduleTimelineLine`과 item별 `TimelineDotView` 구조로 변경하고 item gap을 4pt로 맞췄다.
- 2026-07-01: `rg "VStack\(alignment: \.leading, spacing: SodaSpacing\.s4\)|ScheduleTimelineLine|TimelineDotView|padding\(\.vertical, 52\)|opacity\(monthlySchedules\.count > 1|HStack\(alignment: \.center, spacing: SodaSpacing\.s14\)|localhost:3845" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift`로 연속 line/dot 구조와 Figma localhost asset 미사용을 확인했다.
- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase7" build`를 실행해 timeline 수정 후 빌드 성공을 확인했다. 기존 warning은 남아 있으나 컴파일 오류는 없었다.