From dc99bdf9c92eae946b87a8fb0491eaddac67dc69 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 3 Aug 2026 15:37:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(main):=20=ED=98=84=EC=9E=AC=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 +- SodaLive/Sources/ContentView.swift | 4 +- SodaLive/Sources/I18n/I18n.swift | 2 +- SodaLive/Sources/Live/LiveView.swift | 7 - .../Live/Now/All/LiveNowAllItemView.swift | 191 ++++++++---------- .../Sources/Live/Now/All/LiveNowAllView.swift | 67 +++--- .../Sources/Live/Now/SectionLiveNowView.swift | 4 +- SodaLive/Sources/V2/Main/MainView.swift | 2 +- .../plan-task.md | 50 +++++ docs/20260602_메인_홈_추천_UI_API_연동/prd.md | 3 + 10 files changed, 172 insertions(+), 160 deletions(-) diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index d39450bb..37e01573 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -87,7 +87,7 @@ enum AppStep { onSuccess: (CreateLiveRoomResponse) -> Void ) - case liveNowAll(onClickParticipant: (Int) -> Void) + case liveNowAll case liveReservationAll( onClickReservation: (Int) -> Void, diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index fea56cb8..587287f8 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -216,8 +216,8 @@ struct AppStepLayerView: View { onSuccess: onSuccess ) - case .liveNowAll(let onClickParticipant): - LiveNowAllView(onClickParticipant: onClickParticipant) + case .liveNowAll: + LiveNowAllView() case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive): LiveReservationAllView( diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 7ed2dc23..75d2f935 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -2367,7 +2367,7 @@ enum I18n { enum LiveNow { static var allTitle: String { - pick(ko: "지금 라이브 중 전체보기", en: "Live Now - All", ja: "ライブ配信中(全て)") + pick(ko: "On Air", en: "On Air", ja: "On Air") } static var sectionTitle: String { diff --git a/SodaLive/Sources/Live/LiveView.swift b/SodaLive/Sources/Live/LiveView.swift index b28b23da..890a01d8 100644 --- a/SodaLive/Sources/Live/LiveView.swift +++ b/SodaLive/Sources/Live/LiveView.swift @@ -60,13 +60,6 @@ struct LiveView: View { LazyVStack(spacing: 48) { SectionLiveNowView( items: viewModel.liveNowItems, - onClickParticipant: { - if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - viewModel.enterLiveRoom(roomId: $0) - } else { - AppState.shared.setAppStep(step: .login) - } - }, onTapItem: { item in onTapLiveNowItem(item.roomId, item.isAdult) }, diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift index 1b428afb..abeb6a44 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift @@ -14,117 +14,92 @@ struct LiveNowAllItemView: View { let itemWidth: CGFloat var body: some View { - VStack(alignment: .leading, spacing: 8) { - ZStack { - KFImage(URL(string: item.coverImageUrl)) - .cancelOnDisappear(true) - .downsampling( - size: CGSize( - width: itemWidth, - height: itemWidth * 144 / 102 - ) - ) - .resizable() - .scaledToFill() - .frame(width: itemWidth, height: itemWidth * 144 / 102, alignment: .center) - .cornerRadius(4.7) - .clipped() - - LinearGradient( - colors: [Color.black.opacity(0.1), Color.black.opacity(0.8)], - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .trailing, spacing: 0) { - HStack(spacing: 0) { - HStack(spacing: 1) { - if item.price > 0 { - Image("ic_can_white") - } - - Text(item.price > 0 ? "\(item.price)" : I18n.CreateContent.free) - .appFont(size: 12, weight: .medium) - .foregroundColor(Color.white) - } - .padding(.horizontal, 7.3) - .padding(.vertical, 4) - .background(item.price > 0 ? Color.mainRed3 : Color.gray11) - .cornerRadius(13.3) - - Spacer() - - if item.isPrivateRoom { - Image("ic_lock") - .resizable() - .frame(width: 20, height: 20) - .padding(2.7) - .background(Color.gray33.opacity(0.7)) - .clipShape(Circle()) - } - } - .padding(.horizontal, 3.3) - .padding(.top, 3.3) - - Spacer() - - if item.numberOfPeople - item.numberOfParticipate < 3 { - HStack(spacing: 0) { - Spacer() - - HStack(spacing: 2) { - Text(I18n.LiveNow.remaining) - .appFont(size: 12, weight: .medium) - .foregroundColor(Color.grayee) - - Text("\(item.numberOfPeople - item.numberOfParticipate)") - .appFont(size: 12, weight: .medium) - .foregroundColor(Color.button) - } - .padding(.horizontal, 4) - .padding(.vertical, 3) - .background(Color.gray33.opacity(0.7)) - .cornerRadius(13.3) - } - .padding(.horizontal, 3.3) - .padding(.bottom, 3.3) - } + HStack(alignment: .center, spacing: 14) { + KFImage(URL(string: item.creatorProfileImage)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 75, height: 75)) + .resizable() + .scaledToFill() + .frame(width: 75, height: 75) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + liveTag + + Text(startTimeText) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) } + + Text(item.title) + .appFont(size: 18, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.creatorNickname) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + .truncationMode(.tail) } - .frame(width: itemWidth, height: itemWidth * 144 / 102) - - Text("\(item.title)") - .appFont(size: 12, weight: .medium) - .foregroundColor(Color(hex: "eeeeee")) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - if item.tags.count > 0 { - Text("\(item.tags.map { "#\($0)" }.joined(separator: " "))") - .appFont(size: 11, weight: .medium) - .foregroundColor(Color.button) - } - - HStack(spacing: 5.3) { - KFImage(URL(string: item.creatorProfileImage)) - .cancelOnDisappear(true) - .downsampling( - size: CGSize( - width: 21.3, - height: 21.3 - ) - ) - .resizable() - .scaledToFill() - .frame(width: 21.3, height: 21.3, alignment: .center) - .clipShape(Circle()) - - Text("\(item.creatorNickname)") - .appFont(size: 10, weight: .medium) - .foregroundColor(Color.gray77) - } + .frame(maxWidth: .infinity, alignment: .leading) + + priceView } - .frame(width: itemWidth) + .frame(width: itemWidth, height: 99) + } + + private var liveTag: some View { + HStack(spacing: 4) { + Circle() + .fill(Color.red400) + .frame(width: 8, height: 8) + + Text("LIVE") + .appFont(size: 12, weight: .regular) + .foregroundColor(.white) + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + .frame(height: 18) + .background(Color.black) + .clipShape(Capsule()) + } + + @ViewBuilder + private var priceView: some View { + if item.price > 0 { + HStack(spacing: 2) { + Image("ic_bar_cash") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + + Text("\(item.price)") + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + } + .padding(.trailing, 7) + .frame(width: 60, alignment: .trailing) + } else { + Text(I18n.CreateContent.free) + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + .padding(.trailing, 7) + .frame(width: 60, alignment: .trailing) + } + } + + private var startTimeText: String { + guard let date = DateParser.parse(item.beginDateTimeUtc) else { + return item.beginDateTimeUtc + } + + return date.convertDateFormat(dateFormat: "HH:mm") } } diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift index 7446f13b..04ff111a 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift @@ -13,7 +13,6 @@ import BootpayUI struct LiveNowAllView: View { @StateObject var viewModel = LiveViewModel() - @StateObject var liveAllViewModel = LiveAllViewModel() @StateObject var mypageViewModel = MyPageViewModel() let spacing: CGFloat = 16 @@ -25,15 +24,6 @@ struct LiveNowAllView: View { @State private var isShowAuthConfirmView = false @State private var pendingAction: (() -> Void)? = nil @State private var payload = Payload() - - var columns: [GridItem] { - [ - GridItem(.flexible(), spacing: spacing, alignment: .top), - GridItem(.flexible(), spacing: spacing, alignment: .top) - ] - } - - let onClickParticipant: (Int) -> Void var body: some View { BaseView(isLoading: $viewModel.isLoading) { @@ -50,13 +40,13 @@ struct LiveNowAllView: View { viewModel.getLiveNowList() }, content: { - let itemWidth = (proxy.size.width - (spacing * 3)) / 2 + let itemWidth = proxy.size.width - (spacing * 2) - LazyVGrid(columns: columns, spacing: spacing) { + LazyVStack(spacing: spacing) { ForEach(0.. Void let onTapItem: (GetRoomListResponse) -> Void let onTapCreateLive: () -> Void let onClickRefresh: () -> Void @@ -29,7 +28,7 @@ struct SectionLiveNowView: View { Text(I18n.Common.viewAll) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "78909C")) - .onTapGesture { AppState.shared.setAppStep(step: .liveNowAll(onClickParticipant: onClickParticipant)) } + .onTapGesture { AppState.shared.setAppStep(step: .liveNowAll) } } } .padding(.horizontal, 24) @@ -95,7 +94,6 @@ struct SectionLiveNowView_Previews: PreviewProvider { static var previews: some View { SectionLiveNowView( items: [], - onClickParticipant: { _ in }, onTapItem: { _ in }, onTapCreateLive: {}, onClickRefresh: {} diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index b31aeb5d..321a88da 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -519,7 +519,7 @@ struct MainView: View { } private func handleRecommendationLiveAllTap() { - appState.setAppStep(step: .liveNowAll(onClickParticipant: handleRecommendationLiveTap)) + appState.setAppStep(step: .liveNowAll) } private func handleRecommendationCreatorTap(creatorId: Int) { diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 39fc313b..4c5380f0 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -21,6 +21,56 @@ - 기존 `SodaLive/Sources/Home/HomeApi.swift`에는 `/api/v2/home/recommendations`를 추가하지 않는다. - 외부 라이브러리는 추가하지 않는다. +### 2026-08-03 현재 라이브 전체보기 입장 로딩 표시 완료 + +- 목적: `LiveNowAllView`에서 라이브 항목 탭 후 방 상세 조회/입장 통신 중 멈춘 것처럼 보이지 않도록 기존 로딩 UI 표시 +- TDD 예외 사유: + - SwiftUI 화면 내부 입장 흐름이며 앱 테스트 번들 타깃 검색 결과가 없어 production View를 직접 import하는 영구 UI/unit test를 이번 Task에서 추가할 수 없다. +- 수행 내용: + - `LiveNowAllView`가 기존 `LiveViewModel.enterLiveRoom(roomId:)`를 직접 호출하도록 변경 + - `BaseView(isLoading: $viewModel.isLoading)`를 통해 방 상세 조회/입장 요청 중 기존 `LoadingView` 표시 + - 입장 전 결제 확인 dialog, 비밀방 password dialog, 입장 실패 toast를 `LiveNowAllView`에 연결 + - `AppStep.liveNowAll`과 `SectionLiveNowView`에서 더 이상 필요 없는 입장 콜백을 제거 +- 검증: + - `rg -n "PBXNativeTarget.*Test|UITests|Tests|xctest|SodaLiveTests|SodaLiveUITests" SodaLive.xcodeproj/project.pbxproj` 실행, 테스트 타깃 없음 확인 + - `rg -n "liveNowAll\(|liveNowAll\)|case \.liveNowAll|LiveNowAllView\(|onClickParticipant: onClickParticipant|let onClickParticipant" SodaLive/Sources` 실행 + - `rg -n "BaseView\(isLoading: \$viewModel\.isLoading\)|viewModel\.enterLiveRoom\(roomId: roomId\)|LivePaymentDialog\(|LiveRoomPasswordDialog\(|sodaToast\(isPresented: \$viewModel\.isShowPopup" SodaLive/Sources/Live/Now/All/LiveNowAllView.swift` 실행 + - `git diff --check` 실행 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 + +### 2026-08-03 현재 라이브 전체보기 즉시 입장 변경 완료 + +- 목적: 추천 탭 `전체` 진입 화면에서 라이브 항목 탭 시 상세 오버레이를 거치지 않고 기존 라이브 입장 흐름으로 바로 연결 +- TDD 예외 사유: + - SwiftUI 화면 내부 탭/가드 흐름이며 앱 테스트 번들 타깃이 없어 production View를 직접 import하는 영구 UI/unit test를 이번 Task에서 추가할 수 없다. +- 수행 내용: + - `LiveNowAllView`의 로그인 guard, 한국 사용자 본인인증 guard, 성인 콘텐츠 설정 guard는 유지 + - guard 통과 후 `LiveDetailView` 표시 대신 `onClickParticipant(roomId)`를 직접 호출하도록 변경 + - 기존 상세 Join 버튼 경로와 동일하게 직접 입장 직전 `AppState.shared.isShowPlayer = false` 처리 유지 +- 검증: + - 변경 전 `rg -n "openLiveDetail\(roomId: roomId\)|LiveDetailView\(|liveAllViewModel|onClickParticipant\(liveAllViewModel.selectedRoomId\)" SodaLive/Sources/Live/Now/All/LiveNowAllView.swift` 실행, 상세 오버레이 경유 상태 확인 + - 변경 후 `rg -n "LiveDetailView\(|liveAllViewModel|openLiveDetail\(" SodaLive/Sources/Live/Now/All/LiveNowAllView.swift` 실행, 출력 없음으로 상세 오버레이 경유 제거 확인 + - 변경 후 `rg -n "enterLiveRoom\(roomId: roomId\)|onClickParticipant\(roomId\)|setAppStep\(step: \.login\)|contentViewSettings|isShowAuthConfirmView|pendingAction = \{ handleLiveNowItemTap" SodaLive/Sources/Live/Now/All/LiveNowAllView.swift` 실행, 즉시 입장 호출과 guard 유지 확인 + - `git diff --check` 실행, 출력 없이 성공 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` + +### 2026-08-03 현재 라이브 전체보기 리스트 UI 보정 완료 + +- 목적: 추천 탭 `전체` 진입 화면을 Figma `185:4509` 기준의 On Air 리스트 UI로 표시 +- 수행 내용: + - `I18n.LiveNow.allTitle`을 `On Air`로 변경 + - `LiveNowAllView`의 2열 `LiveNowItemView` 그리드를 단일 `LazyVStack` + `LiveNowAllItemView` 리스트로 변경 + - `LiveNowAllItemView`를 `75x75` 원형 프로필, `LIVE` 태그, `HH:mm` 시작 시간, 18pt bold 제목, 14pt regular 크리에이터명, 우측 가격 영역 구조로 보정 + - 유료 라이브는 `ic_bar_cash`와 가격, 무료 라이브는 가격/캔 아이콘 대신 `무료`만 표시 +- 검증: + - `rg -n "LiveNowItemView\\(|LiveNowAllItemView\\(|DetailNavigationBar\\(title: I18n\\.LiveNow\\.allTitle\\)|static var allTitle|ic_bar_cash|ic_can_white" SodaLive/Sources/Live/Now SodaLive/Sources/I18n/I18n.swift` 실행, 변경 전 전체보기 화면의 2열 카드/기존 아이콘 상태 확인 + - `rg -n "pick\\(ko: \\"On Air\\"|LazyVStack|LiveNowAllItemView\\(item: item|Image\\(\\"ic_bar_cash\\"\\)|I18n.CreateContent.free|frame\\(width: 75, height: 75\\)|appFont\\(size: 18, weight: \\.bold\\)|DateParser.parse" SodaLive/Sources/I18n/I18n.swift SodaLive/Sources/Live/Now/All` 실행, 변경 후 제목/리스트/유료/무료/시간/타이포그래피 근거 확인 + - `git diff --check` 실행, 출력 없이 성공 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` + ### 2026-08-03 Phase 8.2 현재 라이브 전체 항목 스타일 보정 완료 - 목적: 메인 홈 추천 탭 현재 라이브 섹션 마지막 `전체` 항목을 요청한 텍스트 전용 스타일로 표시 diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md index f5627960..b7b0e6b3 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/prd.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/prd.md @@ -190,6 +190,7 @@ enum class RecommendedActivityType(val code: String) { - 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다. - 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다. - 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다. +- 현재 라이브 전체보기에서 라이브 입장 통신 중에는 기존 `LoadingView`로 진행 중임을 표시한다. - 최근 활동 크리에이터에서 서버 원본 `activityType == LIVE`이고 `targetId != null`이면 `targetId`를 라이브룸 `roomId`로 사용해 기존 라이브룸 입장 흐름을 실행한다. - 최근 활동 크리에이터에서 서버 원본 `activityType == LIVE`이고 `targetId == null`이면 `creatorId`를 사용해 크리에이터 채널로 이동한다. - 최근 활동 크리에이터의 `creatorId`는 서버에서 항상 유효한 양수로 제공하지만, 앱에서도 `creatorId > 0`을 확인한 뒤에만 크리에이터 채널 이동을 실행한다. 검사를 통과하지 못하면 이동하지 않는다. @@ -339,6 +340,8 @@ MainHomeView - 추천 탭에서 `GET /api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다. - 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다. - 현재 라이브 섹션의 마지막 `전체` 항목은 배경 없이 medium 14 텍스트, `soda400` 색상, 세로 가운데 정렬로 표시된다. +- 현재 라이브 `전체` 진입 화면의 상단 제목은 `On Air`로 표시하고, 라이브 아이템은 Figma `185:4509`의 리스트형 구조를 따른다. +- 현재 라이브 `전체` 진입 화면의 유료 라이브는 `ic_bar_cash`와 가격을 표시하고, 무료 라이브는 가격과 캔 아이콘 대신 `무료`만 표시한다. - `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다. - `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다. - `FeedCommunityView`에서 키워드 영역이 제거되고, 이미지/유료 미구매/유료 구매함 또는 무료 상태가 조건에 맞게 표시된다.