diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 4c99e9be..8020784e 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -261,6 +261,9 @@ 401C10412FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */; }; 401C10422FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */; }; 401C10432FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */; }; + 401C10452FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */; }; + 401C10462FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */; }; + 401C10472FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.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 */; }; @@ -1800,6 +1803,7 @@ 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingRepository.swift; sourceTree = ""; }; 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingViewModel.swift; sourceTree = ""; }; 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingCreatorSection.swift; sourceTree = ""; }; + 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingLiveSection.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 = ""; }; @@ -2812,6 +2816,7 @@ isa = PBXGroup; children = ( 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */, + 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */, ); path = Components; sourceTree = ""; @@ -5481,6 +5486,7 @@ 401C10132FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */, 401C10142FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10412FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, + 401C10452FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, 40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */, 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */, @@ -6246,6 +6252,7 @@ 401C10232FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */, 401C10242FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10422FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, + 401C10462FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */, A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */, @@ -7041,6 +7048,7 @@ 401C10332FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */, 401C10342FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10432FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, + 401C10472FEF700000A5BBA3 /* MainHomeFollowingLiveSection.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/MainHomeFollowingLiveSection.swift b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift new file mode 100644 index 00000000..2ad68862 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct MainHomeFollowingLiveSection: View { + let onAirLives: [FollowingLiveResponse] + let onTapLive: (Int) -> Void + + init( + onAirLives: [FollowingLiveResponse], + onTapLive: @escaping (Int) -> Void = { _ in } + ) { + self.onAirLives = onAirLives + self.onTapLive = onTapLive + } + + var body: some View { + if !onAirLives.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.HomeFollowing.onAirTitle) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: SodaSpacing.s8) { + ForEach(onAirLives) { live in + MainHomeFollowingLiveItemView(live: live) { + onTapLive(live.liveId) + } + } + } + .padding(.leading, SodaSpacing.s14) + } + } + } + } +} + +private struct MainHomeFollowingLiveItemView: View { + let live: FollowingLiveResponse + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: live.creatorProfileImageUrl), size: CGSize(width: 75, height: 75)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + LiveTagView() + + Text(live.relativeStartedAtText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + Text(live.title) + .appFont(size: 18, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(live.creatorNickname) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: 149, alignment: .leading) + } + .padding(.leading, SodaSpacing.s12) + .padding(.trailing, 22) + .padding(.vertical, SodaSpacing.s12) + .background(Color.gray900) + .clipShape(Capsule()) + .overlay { + Capsule() + .stroke(Color(hex: "62CFFF"), lineWidth: 2) + } + } + .buttonStyle(.plain) + } +} + +private struct LiveTagView: View { + var body: some View { + HStack(alignment: .center, spacing: SodaSpacing.s4) { + Circle() + .fill(Color.red400) + .frame(width: 8, height: 8) + + Text("LIVE") + .appFont(size: 12, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + } + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .frame(height: 18) + .background(Color.black) + .clipShape(Capsule()) + } +} + +private extension FollowingLiveResponse { + func relativeStartedAtText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: startedAtUtc, fallback: startedAtUtc, now: now) + } +} + +struct MainHomeFollowingLiveSection_Previews: PreviewProvider { + private static let previewImageUrl = "https://placehold.co/500" + + static var previews: some View { + MainHomeFollowingLiveSection( + onAirLives: [ + FollowingLiveResponse( + liveId: 1, + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "크리에이터", + title: "오늘 밤 같이 이야기해요", + startedAtUtc: "2026-07-01T00:30:00Z" + ), + FollowingLiveResponse( + liveId: 2, + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "소다", + title: "신규 콘텐츠 제작 비하인드", + startedAtUtc: "2026-07-01T00:10:00Z" + ) + ] + ) + .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 8d528f4a..2c63bc9b 100644 --- a/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift +++ b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift @@ -1,15 +1,18 @@ import SwiftUI struct MainHomeFollowingView: View { + let onTapLive: (Int) -> Void let onTapCreator: (Int) -> Void let onTapFollowingAll: () -> Void @StateObject private var viewModel = MainHomeFollowingViewModel() init( + onTapLive: @escaping (Int) -> Void = { _ in }, onTapCreator: @escaping (Int) -> Void = { _ in }, onTapFollowingAll: @escaping () -> Void = {} ) { + self.onTapLive = onTapLive self.onTapCreator = onTapCreator self.onTapFollowingAll = onTapFollowingAll } @@ -22,6 +25,11 @@ struct MainHomeFollowingView: View { onTapAll: onTapFollowingAll ) + MainHomeFollowingLiveSection( + onAirLives: viewModel.response?.onAirLives ?? [], + onTapLive: onTapLive + ) + MainPlaceholderTabView(title: "팔로잉") } .onAppear { diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift index b1383b44..42e67d96 100644 --- a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift +++ b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift @@ -73,6 +73,7 @@ struct MainHomeView: View { MainHomeRankingView(onTapCreator: onTapCreator) case .following: MainHomeFollowingView( + onTapLive: onTapLive, onTapCreator: onTapCreator, onTapFollowingAll: onTapFollowingAll ) diff --git a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md index 76599df6..78b0f85f 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -236,7 +236,7 @@ ### Phase 5: On Air 섹션 -- [ ] **Task 5.1: On Air 섹션 생성** +- [x] **Task 5.1: On Air 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift` - 작업 내용: @@ -508,3 +508,14 @@ - 2026-07-01: `rg "StateObject private var viewModel = MainHomeFollowingViewModel|onAppear|hasLoaded|fetchFollowing|response\?\.followingCreators|isLoading|message|MainPlaceholderTabView" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift`로 Phase 4 정적 요구사항을 확인했다. - 2026-07-01: `rg "onSelectFollowingTab|Binding\(|selectedTab = \.following|setAppStep\(step: \.login\)|TextTabBar" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`로 비로그인 팔로잉 탭 guard 흐름이 유지됨을 확인했다. - 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 실행해 Phase 4 API 호출 연결 후 빌드 성공을 확인했다. 기존 AppIntents metadata extraction warning은 남아 있으나 컴파일 오류는 없었다. +- 2026-07-01: Phase 5 구현을 진행해 `MainHomeFollowingLiveSection`을 추가하고, `FollowingLiveResponse`의 프로필 이미지/닉네임/라이브 제목/`startedAtUtc` 상대 시간 표시와 `onTapLive(liveId)` callback을 연결했다. +- 2026-07-01: `rg "struct MainHomeFollowingLiveSection|onAirTitle|FollowingLiveResponse|startedAtUtc|DateParser|onTapLive|DownsampledKFImage|ScrollView\(\.horizontal|!onAirLives\.isEmpty" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift`로 Phase 5 정적 요구사항을 확인했다. +- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 실행해 Phase 5 신규 Swift 파일 컴파일과 빌드 성공을 확인했다. 기존 Crashlytics dSYM/AppIntents metadata extraction warning은 남아 있으나 컴파일 오류는 없었다. +- 2026-07-01: 사용자 요청에 따라 Phase 11까지 기다리지 않고 `MainHomeFollowingView`에 `MainHomeFollowingLiveSection`을 조기 조립했다. `viewModel.response?.onAirLives ?? []`를 전달하고, `MainHomeView`의 기존 `onTapLive` callback을 팔로잉 On Air 섹션까지 연결했다. +- 2026-07-01: `SodaLive.xcodeproj/project.pbxproj`에 `MainHomeFollowingLiveSection.swift` 파일 참조와 Sources build file을 추가해 Xcode navigator와 target compile sources에서 바로 확인 가능하도록 등록했다. +- 2026-07-01: `rg "MainHomeFollowingLiveSection.swift|MainHomeFollowingLiveSection.swift in Sources|MainHomeFollowingLiveSection\(" SodaLive.xcodeproj/project.pbxproj SodaLive/Sources/V2/Main/Home`로 Xcode 프로젝트 등록과 화면 조립을 확인했다. +- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 재실행해 프로젝트 등록 후 빌드 성공을 확인했다. 기존 duplicate build file/run script/Crashlytics dSYM/AppIntents warning은 남아 있으나 컴파일 오류는 없었다. +- 2026-07-01: Figma `24:5693`, `24:5696`을 확인해 On Air 섹션을 캡슐형 라이브 아이템으로 수정했다. `75x75` 프로필, 검정 `LIVE` pill, `#62CFFF` 2pt 캡슐 테두리, `14/18/14pt` 텍스트 계층, leading 14/item gap 8 간격을 반영했다. +- 2026-07-01: `rg "CGSize\(width: 75, height: 75\)|Color\(hex: \"62CFFF\"\)|Text\(\"LIVE\"\)|frame\(height: 18\)|padding\(\.trailing, 22\)|padding\(\.leading, SodaSpacing\.s14\)|spacing: SodaSpacing\.s8|appFont\(size: 18, weight: \.bold\)|appFont\(size: 14, weight: \.regular\)" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift`로 Figma 핵심 수치 반영을 확인했다. +- 2026-07-01: `rg "localhost:3845|img_live|frame\(width: 50, height: 18\)|CGSize\(width: 62, height: 62\)|RoundedRectangle" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingLiveSection.swift` 결과가 없어 Figma localhost asset 미사용과 기존 배지/카드 형태 제거를 확인했다. +- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 실행해 Figma 반영 후 빌드 성공을 확인했다. 기존 Crashlytics dSYM/AppIntents warning은 남아 있으나 컴파일 오류는 없었다.