diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 8f02133a..6f1bea21 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -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 = ""; }; 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingLiveSection.swift; sourceTree = ""; }; 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingChatSection.swift; sourceTree = ""; }; + 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingScheduleSection.swift; sourceTree = ""; }; 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = ""; }; 40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = ""; }; @@ -2822,6 +2826,7 @@ 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */, 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */, 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */, + 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */, ); path = Components; sourceTree = ""; @@ -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 */, diff --git a/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift new file mode 100644 index 00000000..10d4594d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingScheduleSection.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift index 3ccb69cb..b46d8220 100644 --- a/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift +++ b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift @@ -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 { diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift index da602ec7..b2e8bbeb 100644 --- a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift +++ b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift @@ -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 } ) } } diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 245fe69e..7cacf704 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -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 { diff --git a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md index a1400757..86984766 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -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은 남아 있으나 컴파일 오류는 없었다.