diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 8020784e..8f02133a 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -264,6 +264,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -1804,6 +1807,7 @@ 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 = ""; }; + 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingChatSection.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 = ""; }; @@ -2817,6 +2821,7 @@ children = ( 401C10442FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift */, 401C10482FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift */, + 401C10492FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift */, ); path = Components; sourceTree = ""; @@ -5487,6 +5492,7 @@ 401C10142FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10412FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, 401C10452FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */, + 401C104A2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, 40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */, 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */, @@ -6253,6 +6259,7 @@ 401C10242FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10422FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, 401C10462FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */, + 401C104B2FEF900000A5BBA3 /* MainHomeFollowingChatSection.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */, A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */, @@ -7049,6 +7056,7 @@ 401C10342FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */, 401C10432FEF500000A5BBA3 /* MainHomeFollowingCreatorSection.swift in Sources */, 401C10472FEF700000A5BBA3 /* MainHomeFollowingLiveSection.swift in Sources */, + 401C104C2FEF900000A5BBA3 /* MainHomeFollowingChatSection.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/MainHomeFollowingChatSection.swift b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift new file mode 100644 index 00000000..18f4da0e --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct MainHomeFollowingChatSection: View { + let recentChats: [ChatRoomListItemResponse] + let onTapTitle: () -> Void + let onTapChatRoom: (Int) -> Void + + init( + recentChats: [ChatRoomListItemResponse], + onTapTitle: @escaping () -> Void = {}, + onTapChatRoom: @escaping (Int) -> Void = { _ in } + ) { + self.recentChats = recentChats + self.onTapTitle = onTapTitle + self.onTapChatRoom = onTapChatRoom + } + + var body: some View { + if !recentChats.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.HomeFollowing.recentChatTitle, action: onTapTitle) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: SodaSpacing.s8) { + ForEach(recentChats) { chat in + MainHomeFollowingChatItemView(chat: chat) { + onTapChatRoom(chat.roomId) + } + } + } + .padding(.leading, SodaSpacing.s14) + } + } + } + } +} + +private struct MainHomeFollowingChatItemView: View { + let chat: ChatRoomListItemResponse + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: SodaSpacing.s12) { + DownsampledKFImage(url: URL(string: chat.targetImageUrl), size: CGSize(width: 64, height: 64)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + HStack(alignment: .top, spacing: SodaSpacing.s6) { + if chat.isDirectMessage { + DirectTagView() + } + + Spacer(minLength: 0) + + Text(chat.relativeLastMessageAtText()) + .appFont(size: 12, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + .frame(height: 19) + + Text(chat.targetName) + .appFont(size: 14, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(chat.lastMessage) + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SodaSpacing.s14) + .frame(width: 288, alignment: .leading) + .background(Color.gray900) + .cornerRadius(14) + } + .buttonStyle(.plain) + } +} + +private struct DirectTagView: View { + var body: some View { + Text("Direct") + .appFont(size: 12, weight: .bold) + .italic() + .foregroundColor(.white) + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .background(Color.soda400) + .cornerRadius(4) + } +} + +private extension ChatRoomListItemResponse { + var isDirectMessage: Bool { + chatType == "DM" + } + + func relativeLastMessageAtText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: lastMessageAt, fallback: lastMessageAt, now: now) + } +} + +struct MainHomeFollowingChatSection_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/500/500" + + static var previews: some View { + MainHomeFollowingChatSection( + recentChats: [ + ChatRoomListItemResponse( + roomId: 1, + chatType: "DM", + targetName: "크리에이터 이름", + targetImageUrl: previewImageUrl, + lastMessage: "마지막 대화 내용이 들어가는 부분 한줄만", + lastMessageAt: "2026-07-01T01:30:00Z" + ), + ChatRoomListItemResponse( + roomId: 2, + chatType: "GROUP", + targetName: "소다", + targetImageUrl: previewImageUrl, + lastMessage: "다음 대화 내용입니다", + lastMessageAt: "2026-07-01T01:20: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 2c63bc9b..3ccb69cb 100644 --- a/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift +++ b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift @@ -4,17 +4,23 @@ struct MainHomeFollowingView: View { let onTapLive: (Int) -> Void let onTapCreator: (Int) -> Void let onTapFollowingAll: () -> Void + let onTapChatTab: () -> Void + let onTapChatRoom: (Int) -> Void @StateObject private var viewModel = MainHomeFollowingViewModel() init( onTapLive: @escaping (Int) -> Void = { _ in }, onTapCreator: @escaping (Int) -> Void = { _ in }, - onTapFollowingAll: @escaping () -> Void = {} + onTapFollowingAll: @escaping () -> Void = {}, + onTapChatTab: @escaping () -> Void = {}, + onTapChatRoom: @escaping (Int) -> Void = { _ in } ) { self.onTapLive = onTapLive self.onTapCreator = onTapCreator self.onTapFollowingAll = onTapFollowingAll + self.onTapChatTab = onTapChatTab + self.onTapChatRoom = onTapChatRoom } var body: some View { @@ -30,6 +36,14 @@ struct MainHomeFollowingView: View { onTapLive: onTapLive ) + if let recentChats = viewModel.response?.recentChats, !recentChats.isEmpty { + MainHomeFollowingChatSection( + recentChats: recentChats, + onTapTitle: onTapChatTab, + onTapChatRoom: onTapChatRoom + ) + } + MainPlaceholderTabView(title: "팔로잉") } .onAppear { diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift index 42e67d96..da602ec7 100644 --- a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift +++ b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift @@ -13,6 +13,8 @@ struct MainHomeView: View { let onTapFollowAll: (@escaping () -> Void) -> Void let onSelectFollowingTab: () -> Bool let onTapFollowingAll: () -> Void + let onTapChatTab: () -> Void + let onTapChatRoom: (Int) -> Void @State private var selectedTab: MainHomeTab = .recommendation @@ -75,7 +77,9 @@ struct MainHomeView: View { MainHomeFollowingView( onTapLive: onTapLive, onTapCreator: onTapCreator, - onTapFollowingAll: onTapFollowingAll + onTapFollowingAll: onTapFollowingAll, + onTapChatTab: onTapChatTab, + onTapChatRoom: onTapChatRoom ) } } @@ -112,7 +116,9 @@ struct MainHomeView_Previews: PreviewProvider { onTapBanner: { _ in }, onTapFollowAll: { action in action() }, onSelectFollowingTab: { true }, - onTapFollowingAll: {} + onTapFollowingAll: {}, + onTapChatTab: {}, + onTapChatRoom: { _ in } ) } } diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 8364a2ed..245fe69e 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -165,7 +165,9 @@ struct MainView: View { onTapBanner: handleRecommendationBannerTap, onTapFollowAll: handleRecommendationFollowAllTap, onSelectFollowingTab: handleFollowingTabSelection, - onTapFollowingAll: handleFollowingAllTap + onTapFollowingAll: handleFollowingAllTap, + onTapChatTab: handleFollowingChatTabTap, + onTapChatRoom: handleFollowingChatRoomTap ) case .content: MainPlaceholderTabView(title: MainTab.content.title) @@ -490,6 +492,15 @@ struct MainView: View { appState.setAppStep(step: .followingList) } + private func handleFollowingChatTabTap() { + viewModel.currentTab = .chat + } + + private func handleFollowingChatRoomTap(roomId: Int) { + guard roomId > 0 else { return } + appState.setAppStep(step: .chatRoom(id: roomId)) + } + 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 78b0f85f..a1400757 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -252,7 +252,7 @@ ### Phase 6: 최근 대화 섹션 -- [ ] **Task 6.1: 최근 대화 섹션 생성** +- [x] **Task 6.1: 최근 대화 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` - 확인: `SodaLive/Sources/Chat/Talk/TalkView.swift` @@ -261,14 +261,14 @@ - `recentChats`가 비어 있으면 parent에서 섹션을 렌더링하지 않는다. - 섹션 타이틀은 `I18n.HomeFollowing.recentChatTitle`을 사용한다. - 각 item은 `targetImageUrl`, `targetName`, `lastMessage`, `lastMessageAt`을 표시한다. - - `chatType`은 현재 PRD에서 노출 요구가 없으므로 표시하지 않는다. + - `chatType == "DM"`이면 `Direct` 태그를 표시한다. - `lastMessageAt`은 UTC 기준 문자열로 받아 상대 시간 또는 기존 Talk item과 맞는 시간 label로 표시한다. - item 탭 시 `onTapChatRoom(roomId)`를 호출한다. - 검증 기준: - - 실행 명령: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|onTapChatRoom" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` - - 기대 결과: 최근 대화 item 필드와 채팅방 tap callback이 확인된다. + - 실행 명령: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|chatType == \"DM\"|Direct|onTapChatRoom" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift` + - 기대 결과: 최근 대화 item 필드, `Direct` 태그 조건, 채팅방 tap callback이 확인된다. -- [ ] **Task 6.2: 채팅방 진입 연결** +- [x] **Task 6.2: 채팅방 진입 연결** - 대상 파일: - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` - 수정: `SodaLive/Sources/V2/Main/MainView.swift` @@ -519,3 +519,11 @@ - 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은 남아 있으나 컴파일 오류는 없었다. +- 2026-07-01: Phase 6 구현을 진행해 `MainHomeFollowingChatSection`을 추가하고, `recentChats`를 Figma `24:5698`, `24:5701` 기준의 가로 카드 섹션으로 표시하도록 조립했다. `chatType == "DM"`일 때만 `Direct` 태그를 표시하고, `lastMessageAt`은 `DateParser.relativeTimeText(fromUTC:fallback:now:)`로 표시한다. +- 2026-07-01: `MainHomeFollowingView -> MainHomeView -> MainView.handleFollowingChatRoomTap(roomId:)` 경로로 최근 대화 item tap을 `.chatRoom(id:)` 라우팅에 연결했다. +- 2026-07-01: `SodaLive.xcodeproj/project.pbxproj`에 `MainHomeFollowingChatSection.swift` 파일 참조와 Sources build file을 추가해 Xcode navigator와 target compile sources에서 바로 확인 가능하도록 등록했다. +- 2026-07-01: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|DateParser|onTapChatRoom|ScrollView\(\.horizontal|Color\.gray900|frame\(width: 288|CGSize\(width: 64, height: 64\)|chatType == \"DM\"|Direct" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift`로 최근 대화 섹션 핵심 요구사항과 Direct 태그 표시 조건을 확인했다. +- 2026-07-01: `rg "onTapChatRoom|MainHomeFollowingChatSection|response\?\.recentChats|chatRoom\(id:|ChatRoomView" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`로 최근 대화 섹션 조립과 채팅방 라우팅 연결을 확인했다. +- 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은 남아 있으나 컴파일 오류는 없었다. diff --git a/docs/20260630_메인_홈_팔로잉_탭/prd.md b/docs/20260630_메인_홈_팔로잉_탭/prd.md index e2291280..2e5a4a3a 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/prd.md +++ b/docs/20260630_메인_홈_팔로잉_탭/prd.md @@ -375,7 +375,7 @@ Figma 참조: - `recentChats`가 비어 있으면 섹션을 숨긴다. - 섹션 타이틀은 `최근 대화`를 사용한다. - `ChatRoomListItemResponse.roomId`를 item id와 채팅방 진입 id로 사용한다. -- `chatType`은 서버 문자열 그대로 디코딩하고, 현재 화면에서는 별도 tag 표시 요구가 없으면 노출하지 않는다. +- `chatType == "DM"`이면 `Direct` 태그를 표시한다. - `targetName`은 채팅방 제목으로 표시한다. - `targetImageUrl`은 채팅방 이미지로 표시한다. - `lastMessage`는 최근 메시지 preview로 표시한다.