From 5b679389a6c3d67c4f78abb3ff2ff0a6fbe60fe5 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 1 Jul 2026 16:53:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=86=8C=EC=8B=9D=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 8 + .../MainHomeFollowingContentNewsCard.swift | 175 ++++++++++++++++++ .../MainHomeFollowingNewsSection.swift | 134 ++++++++++---- docs/20260630_메인_홈_팔로잉_탭/plan-task.md | 8 +- 4 files changed, 284 insertions(+), 41 deletions(-) create mode 100644 SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 8c0c7f9e..cdb21d7b 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -276,6 +276,9 @@ 401C10552FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10582FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift */; }; 401C10562FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10582FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift */; }; 401C10572FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10582FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift */; }; + 401C105A2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10592FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift */; }; + 401C105B2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10592FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift */; }; + 401C105C2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10592FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.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 */; }; @@ -1820,6 +1823,7 @@ 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingScheduleSection.swift; sourceTree = ""; }; 401C10542FEFC00000A5BBA3 /* MainHomeFollowingRankingNewsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingRankingNewsCard.swift; sourceTree = ""; }; 401C10582FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingNewsSection.swift; sourceTree = ""; }; + 401C10592FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingContentNewsCard.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 = ""; }; @@ -2836,6 +2840,7 @@ 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */, 401C10502FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift */, 401C10542FEFC00000A5BBA3 /* MainHomeFollowingRankingNewsCard.swift */, + 401C10592FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift */, 401C10582FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift */, ); path = Components; @@ -5510,6 +5515,7 @@ 401C104A2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */, 401C104D2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */, 401C10512FEFC00000A5BBA3 /* MainHomeFollowingRankingNewsCard.swift in Sources */, + 401C105A2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */, 401C10552FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, 40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */, @@ -6280,6 +6286,7 @@ 401C104B2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */, 401C104E2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */, 401C10522FEFC00000A5BBA3 /* MainHomeFollowingRankingNewsCard.swift in Sources */, + 401C105B2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */, 401C10562FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */, @@ -7080,6 +7087,7 @@ 401C104C2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */, 401C104F2FEFA00000A5BBA3 /* MainHomeFollowingScheduleSection.swift in Sources */, 401C10532FEFC00000A5BBA3 /* MainHomeFollowingRankingNewsCard.swift in Sources */, + 401C105C2FEFE00000A5BBA3 /* MainHomeFollowingContentNewsCard.swift in Sources */, 401C10572FEFD00000A5BBA3 /* MainHomeFollowingNewsSection.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, 33BA3AB731849284E62AE04B /* MainTab.swift in Sources */, diff --git a/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift new file mode 100644 index 00000000..e184c03f --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift @@ -0,0 +1,175 @@ +import SwiftUI + +struct MainHomeFollowingContentNewsCard: View { + enum Variant { + case audio + case photo + + var tagTitle: String { + switch self { + case .audio: + return I18n.HomeFollowing.audioTag + case .photo: + return I18n.HomeFollowing.photoTag + } + } + + var thumbnailAspectRatio: CGFloat { + switch self { + case .audio: + return 1 + case .photo: + return 88.0 / 118.0 + } + } + + var thumbnailHeight: CGFloat { + switch self { + case .audio: + return 88 + case .photo: + return 118 + } + } + } + + let content: FollowingContentNewsResponse + let visibleFromAtUtc: String + let variant: Variant + let onTapContent: (Int) -> Void + + init( + content: FollowingContentNewsResponse, + visibleFromAtUtc: String, + variant: Variant, + onTapContent: @escaping (Int) -> Void = { _ in } + ) { + self.content = content + self.visibleFromAtUtc = visibleFromAtUtc + self.variant = variant + self.onTapContent = onTapContent + } + + var body: some View { + Button { + onTapContent(content.contentId) + } label: { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + thumbnail + + ZStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: SodaSpacing.s6) { + creatorProfile + + Text(content.title) + .appFont(size: 18, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + ZStack(alignment: .center) { + ContentTagView(title: variant.tagTitle) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(relativeVisibleFromText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(height: 23) + } + .frame(height: variant.thumbnailHeight, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SodaSpacing.s14) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14, style: .continuous)) + } + .buttonStyle(.plain) + } + + private var thumbnail: some View { + DownsampledKFImage( + url: content.contentImageUrl.flatMap(URL.init(string:)), + size: CGSize(width: 88, height: variant.thumbnailHeight) + ) + .background(Color.gray800) + .aspectRatio(variant.thumbnailAspectRatio, contentMode: .fit) + .frame(width: 88) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14, style: .continuous)) + } + + private var creatorProfile: some View { + HStack(spacing: SodaSpacing.s4) { + DownsampledKFImage( + url: URL(string: content.creatorProfileImageUrl), + size: CGSize(width: 20, height: 20) + ) + .clipShape(Circle()) + + Text(content.creatorNickname) + .appFont(size: 12, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + } + } + + private func relativeVisibleFromText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: visibleFromAtUtc, fallback: visibleFromAtUtc, now: now) + } +} + +private struct ContentTagView: View { + let title: String + + var body: some View { + Text(title) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray100) + .lineLimit(1) + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .background(Color.gray700) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r4, style: .continuous)) + } +} + +struct MainHomeFollowingContentNewsCard_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/500/500" + + static var previews: some View { + VStack(spacing: SodaSpacing.s8) { + MainHomeFollowingContentNewsCard( + content: FollowingContentNewsResponse( + contentId: 1, + contentImageUrl: previewImageUrl, + title: "콘텐츠 이름", + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "크리에이터이름" + ), + visibleFromAtUtc: "2026-07-01T00:00:00Z", + variant: .audio + ) + + MainHomeFollowingContentNewsCard( + content: FollowingContentNewsResponse( + contentId: 2, + contentImageUrl: previewImageUrl, + title: "콘텐츠 이름", + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "크리에이터이름" + ), + visibleFromAtUtc: "2026-07-01T00:00:00Z", + variant: .photo + ) + } + .padding(SodaSpacing.s14) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift index 9e3ce1ac..17be87ab 100644 --- a/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift +++ b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift @@ -67,7 +67,27 @@ struct MainHomeFollowingNewsSection: View { ) } - case .audioContent, .photoContent, .unknown: + case .audioContent: + if let audioContent = news.audioContent { + MainHomeFollowingContentNewsCard( + content: audioContent, + visibleFromAtUtc: news.visibleFromAtUtc, + variant: .audio, + onTapContent: onTapContent + ) + } + + case .photoContent: + if let photoContent = news.photoContent { + MainHomeFollowingContentNewsCard( + content: photoContent, + visibleFromAtUtc: news.visibleFromAtUtc, + variant: .photo, + onTapContent: onTapContent + ) + } + + case .unknown: EmptyView() } } @@ -83,46 +103,80 @@ struct MainHomeFollowingNewsSection_Previews: PreviewProvider { private static let previewImageUrl = "https://picsum.photos/500/500" static var previews: some View { - MainHomeFollowingNewsSection( - recentNews: [ - FollowingNewsResponse( - newsId: "creator-ranking", - type: .creatorRanking, - visibleFromAtUtc: "2026-07-01T00:00:00Z", - creatorRanking: FollowingCreatorRankingNewsResponse( - rank: 2, - creatorId: 1, - nickname: "크리에이터", - profileImageUrl: previewImageUrl + ScrollView(.vertical) { + MainHomeFollowingNewsSection( + recentNews: [ + FollowingNewsResponse( + newsId: "creator-ranking", + type: .creatorRanking, + visibleFromAtUtc: "2026-07-01T00:00:00Z", + creatorRanking: FollowingCreatorRankingNewsResponse( + rank: 2, + creatorId: 1, + nickname: "크리에이터", + profileImageUrl: previewImageUrl + ), + audioContent: nil, + photoContent: nil, + contentRanking: nil, + communityPost: nil ), - audioContent: nil, - photoContent: nil, - contentRanking: nil, - communityPost: nil - ), - FollowingNewsResponse( - newsId: "community-post", - type: .communityPost, - visibleFromAtUtc: "2026-07-01T00:00:00Z", - creatorRanking: nil, - audioContent: nil, - photoContent: nil, - contentRanking: nil, - communityPost: FollowingCommunityPostNewsResponse( - postId: 10, - creatorProfileImage: previewImageUrl, - creatorNickname: "크리에이터", - imageUrl: previewImageUrl, - content: "팔로잉 크리에이터의 커뮤니티 게시글입니다.", - createdAt: "2026-07-01T00:00:00Z", - likeCount: 5, - commentCount: 2 + FollowingNewsResponse( + newsId: "community-post", + type: .communityPost, + visibleFromAtUtc: "2026-07-01T00:00:00Z", + creatorRanking: nil, + audioContent: nil, + photoContent: nil, + contentRanking: nil, + communityPost: FollowingCommunityPostNewsResponse( + postId: 10, + creatorProfileImage: previewImageUrl, + creatorNickname: "크리에이터", + imageUrl: previewImageUrl, + content: "팔로잉 크리에이터의 커뮤니티 게시글입니다.", + createdAt: "2026-07-01T00:00:00Z", + likeCount: 5, + commentCount: 2 + ) + ), + FollowingNewsResponse( + newsId: "audio-content", + type: .audioContent, + visibleFromAtUtc: "2026-07-01T00:02:00Z", + creatorRanking: nil, + audioContent: FollowingContentNewsResponse( + contentId: 20, + contentImageUrl: previewImageUrl, + title: "오디오 콘텐츠 이름", + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "크리에이터이름" + ), + photoContent: nil, + contentRanking: nil, + communityPost: nil + ), + FollowingNewsResponse( + newsId: "photo-content", + type: .photoContent, + visibleFromAtUtc: "2026-07-01T00:03:00Z", + creatorRanking: nil, + audioContent: nil, + photoContent: FollowingContentNewsResponse( + contentId: 21, + contentImageUrl: previewImageUrl, + title: "화보 콘텐츠 이름", + creatorProfileImageUrl: previewImageUrl, + creatorNickname: "크리에이터이름" + ), + contentRanking: nil, + communityPost: nil ) - ) - ] - ) - .padding(.vertical, SodaSpacing.s20) - .background(Color.black) - .previewLayout(.sizeThatFits) + ] + ) + .padding(.vertical, SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } } } diff --git a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md index 53a74baf..a4eee359 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -361,7 +361,7 @@ ### Phase 10: 최근 소식 오디오/화보 콘텐츠 카드 -- [ ] **Task 10.1: 최근 소식 콘텐츠 카드 생성** +- [x] **Task 10.1: 최근 소식 콘텐츠 카드 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift` - 작업 내용: @@ -548,3 +548,9 @@ - 2026-07-01: `rg "struct MainHomeFollowingRankingNewsCard|FollowingCreatorRankingNewsResponse|FollowingContentRankingNewsResponse|rank|Color.soda400|onTapCreator|onTapContent|24:5717" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingRankingNewsCard.swift docs/20260630_메인_홈_팔로잉_탭/plan-task.md`로 Phase 8 정적 요구사항을 확인했다. - 2026-07-01: `rg "localhost:3845|// Figma|/\*|english\(" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingRankingNewsCard.swift` 결과가 없어 Figma localhost asset, 불필요한 주석, 잘못된 font family 호출이 없음을 확인했다. - 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase8" build`를 실행해 Phase 8 신규 Swift 파일 컴파일과 빌드 성공을 확인했다. 기존 duplicate build file/run script/AppIntents/dependency scan warning은 남아 있으나 컴파일 오류는 없었다. +- 2026-07-01: Figma `1229:27212`, `1229:27213`을 확인해 Phase 10 최근 소식 오디오/화보 콘텐츠 카드 `MainHomeFollowingContentNewsCard`를 추가했다. 오디오는 88x88 정사각형 썸네일과 `I18n.HomeFollowing.audioTag`, 화보는 88x118 세로형 썸네일과 `I18n.HomeFollowing.photoTag`를 variant로 처리하고, `contentImageUrl`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `visibleFromAtUtc` 표시 및 `onTapContent(contentId)` callback을 반영했다. +- 2026-07-01: `SodaLive.xcodeproj/project.pbxproj`에 `MainHomeFollowingContentNewsCard.swift` 파일 참조와 Sources build file을 추가해 Xcode navigator와 target compile sources에서 확인 가능하도록 등록했다. +- 2026-07-01: `rg "struct MainHomeFollowingContentNewsCard|audioTag|photoTag|contentImageUrl|creatorProfileImageUrl|creatorNickname|visibleFromAtUtc|onTapContent|aspectRatio" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift`로 Phase 10 정적 요구사항을 확인했다. +- 2026-07-01: `rg "localhost:3845|imgRectangle|http://localhost" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingContentNewsCard.swift` 결과가 없어 Figma localhost asset 미사용을 확인했다. +- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase10Test"`는 `Scheme SodaLive is not currently configured for the test action.`으로 실패해 테스트 액션 미구성 상태를 확인했다. +- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase10" build`를 실행해 Phase 10 신규 Swift 파일 컴파일과 빌드 성공을 확인했다. 기존 duplicate build file/run script/AppIntents/dependency scan warning은 남아 있으나 컴파일 오류는 없었다.