Compare commits

6 Commits

Author SHA1 Message Date
Yu Sung
9b115f3339 fix(main): 팔로잉 빈 섹션을 숨긴다 2026-07-13 16:15:19 +09:00
Yu Sung
dbf696978f fix(chat): Direct 대화방 진입을 분기한다 2026-07-12 23:40:39 +09:00
Yu Sung
c9570fc735 fix(chat): 채팅 쿼터 구매 동선을 정리한다 2026-07-12 21:59:04 +09:00
Yu Sung
4f271a77ee fix(creator): 본인 채널 DM 확인을 추가한다 2026-07-12 18:48:27 +09:00
Yu Sung
caa74c607d chore(project): 빌드 번호를 올린다 2026-07-12 17:33:28 +09:00
Yu Sung
c38c8656e3 fix(main): 보이스온 전용 카드 크기를 조정한다 2026-07-12 17:33:05 +09:00
30 changed files with 160 additions and 75 deletions

View File

@@ -8924,7 +8924,7 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES;
@@ -8965,7 +8965,7 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES;

View File

@@ -64,6 +64,7 @@ class AppState: ObservableObject {
@Published var eventPopup: EventItem? = nil
@Published var purchasedContentId = 0
@Published var purchasedContentOrderType = OrderType.KEEP
@Published private(set) var pendingMainChatFilter: MainChatFilter? = nil
@Published var isRestartApp = false
@Published var startTab: HomeViewModel.CurrentTab = .home
@@ -192,6 +193,16 @@ class AppState: ObservableObject {
return message
}
func setPendingMainChatFilter(_ filter: MainChatFilter) {
pendingMainChatFilter = filter
}
func consumePendingMainChatFilter() -> MainChatFilter? {
let filter = pendingMainChatFilter
pendingMainChatFilter = nil
return filter
}
// ( -> ) UI
func softRestart() {
isRestartApp = true

View File

@@ -149,9 +149,6 @@ struct ChatRoomView: View {
if viewModel.showQuotaNoticeView {
ChatQuotaNoticeItemView(
onSelectAd: {
viewModel.showRewardedAdForChatQuota()
},
onSelectCan10: {
viewModel.purchaseChatQuota(canOption: .can10)
},

View File

@@ -279,9 +279,16 @@ final class ChatRoomViewModel: ObservableObject {
func showRewardedAdForChatQuota() {
_Concurrency.Task {
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
let isShown = await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
}
if !isShown {
await MainActor.run {
errorMessage = I18n.Chat.Room.rewardedAdLoadFailed
isShowPopup = true
}
}
}
}

View File

@@ -9,31 +9,11 @@ import SwiftUI
struct ChatQuotaNoticeItemView: View {
let onSelectAd: () -> Void
let onSelectCan10: () -> Void
let onSelectCan20: () -> Void
var body: some View {
VStack(spacing: 10) {
Button {
onSelectAd()
} label: {
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
.appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "263238"))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.frame(maxWidth: .infinity)
.background(Color(hex: "FEF8E3"))
.cornerRadius(30)
.overlay {
RoundedRectangle(cornerRadius: 30)
.stroke(lineWidth: 1)
.foregroundColor(Color(hex: "F7CB50"))
}
.buttonStyle(.plain)
HStack(spacing: 8) {
Button {
onSelectCan10()
@@ -80,7 +60,6 @@ struct ChatQuotaNoticeItemView: View {
#Preview {
ChatQuotaNoticeItemView(
onSelectAd: {},
onSelectCan10: {},
onSelectCan20: {}
)

View File

@@ -300,6 +300,14 @@ enum I18n {
)
}
static var rewardedAdLoadFailed: String {
pick(
ko: "광고를 불러오지 못했어요\n잠시 후 다시 시도해 주세요.",
en: "Could not load the ad.\nPlease try again later.",
ja: "広告を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var backgroundSelectionTitle: String {
pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択")
}
@@ -3247,9 +3255,9 @@ If you block this user, the following features will be restricted.
enum HomeFollowing {
static var emptyStateMessage: String {
pick(
ko: "팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "No following updates yet.\nFollow creators you care about.",
ja: "フォロー中の新着情報はまだありません。\n気になるクリエイターをフォローしてみましょう。"
ko: "아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "There are no following updates yet.\nFollow creators you're interested in.",
ja: "まだフォロー中の新着情報ありません。\n気になるクリエイターをフォローしてみましょう。"
)
}
@@ -3435,6 +3443,7 @@ If you block this user, the following features will be restricted.
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") }
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }

View File

@@ -123,7 +123,8 @@ struct CreatorChannelAudioTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -26,9 +26,11 @@ final class CreatorChannelAudioViewModel: ObservableObject {
@Published var isShowPopup = false
var displayThemes: [CreatorChannelAudioThemeItem] {
let themes = response?.themes.map {
guard let response else { return [] }
let themes = response.themes.map {
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
} ?? []
}
return [CreatorChannelAudioThemeItem.all] + themes
}

View File

@@ -10,7 +10,7 @@ struct CreatorChannelCommunityEmptyView: View {
.lineSpacing(SodaSpacing.s4)
}
.frame(maxWidth: .infinity)
.padding(.top, 316)
.padding(.top, 20)
.padding(.bottom, 120)
}
}

View File

@@ -3,6 +3,7 @@ import Kingfisher
struct CreatorChannelHeaderSection: View {
let creator: CreatorChannelCreatorResponse
let isOwnCreatorChannel: Bool
let onTapTalk: () -> Void
let onTapDm: () -> Void
@@ -54,6 +55,7 @@ struct CreatorChannelHeaderSection: View {
CreatorChannelTalkActionSection(
isAiChatAvailable: creator.isAiChatAvailable,
isDmAvailable: creator.isDmAvailable,
isOwnCreatorChannel: isOwnCreatorChannel,
onTapTalk: onTapTalk,
onTapDm: onTapDm
)

View File

@@ -236,6 +236,7 @@ struct CreatorChannelView: View {
if let creator = viewModel.response?.creator {
CreatorChannelHeaderSection(
creator: creator,
isOwnCreatorChannel: isOwnCreatorChannel,
onTapTalk: handleTalkTap,
onTapDm: handleDmTap
)
@@ -557,6 +558,12 @@ struct CreatorChannelView: View {
return
}
if isOwnCreatorChannel {
AppState.shared.setPendingMainChatFilter(.dm)
AppState.shared.setAppStep(step: .main)
return
}
AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId))
}

View File

@@ -36,7 +36,8 @@ struct CreatorChannelDonationEmptyView: View {
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -36,6 +36,7 @@ struct CreatorChannelFanTalkEmptyView: View {
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct CreatorChannelTalkActionSection: View {
let isAiChatAvailable: Bool
let isDmAvailable: Bool
let isOwnCreatorChannel: Bool
let onTapTalk: () -> Void
let onTapDm: () -> Void
@@ -37,7 +38,7 @@ struct CreatorChannelTalkActionSection: View {
.resizable()
.frame(width: 20, height: 20)
Text(I18n.CreatorChannelHome.dm)
Text(isOwnCreatorChannel ? I18n.CreatorChannelHome.checkDm : I18n.CreatorChannelHome.dm)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
}

View File

@@ -105,7 +105,8 @@ struct CreatorChannelLiveTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -94,7 +94,8 @@ struct CreatorChannelSeriesTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -5,6 +5,7 @@ struct MainChatView: View {
let onTapSearch: () -> Void
@StateObject private var viewModel = MainChatViewModel()
@StateObject private var appState = AppState.shared
var body: some View {
VStack(spacing: 0) {
@@ -28,7 +29,14 @@ struct MainChatView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.black.ignoresSafeArea())
.onAppear { viewModel.fetchFirstPageIfNeeded() }
.onAppear {
if applyPendingFilterIfNeeded() == false {
viewModel.fetchFirstPageIfNeeded()
}
}
.valueChanged(value: appState.pendingMainChatFilter) { _ in
_ = applyPendingFilterIfNeeded()
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
@@ -89,6 +97,12 @@ struct MainChatView: View {
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
}
}
private func applyPendingFilterIfNeeded() -> Bool {
guard let filter = appState.consumePendingMainChatFilter() else { return false }
viewModel.applyFilter(filter)
return true
}
}
struct MainChatView_Previews: PreviewProvider {

View File

@@ -6,6 +6,15 @@ struct MainContentVoiceOnOnlySection: View {
let action: (() -> Void)?
let onTapSeries: (Int) -> Void
private let baseDeviceWidth: CGFloat = 402
private let maxItemWidth: CGFloat = 163
private let maxItemHeight: CGFloat = 230
private var itemSize: CGSize {
let scale = min(1, UIScreen.main.bounds.width / baseDeviceWidth)
return CGSize(width: maxItemWidth * scale, height: maxItemHeight * scale)
}
var body: some View {
if !items.isEmpty {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
@@ -20,13 +29,13 @@ struct MainContentVoiceOnOnlySection: View {
ZStack(alignment: .topLeading) {
DownsampledKFImage(
url: URL(string: item.coverImageUrl),
size: CGSize(width: 122, height: 172)
size: itemSize
)
.background(Color.gray800)
originalTag
}
.frame(width: 122, height: 172)
.frame(width: itemSize.width, height: itemSize.height)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)

View File

@@ -3,12 +3,12 @@ import SwiftUI
struct MainHomeFollowingChatSection: View {
let recentChats: [ChatRoomListItemResponse]
let onTapTitle: () -> Void
let onTapChatRoom: (Int) -> Void
let onTapChatRoom: (Int, String) -> Void
init(
recentChats: [ChatRoomListItemResponse],
onTapTitle: @escaping () -> Void = {},
onTapChatRoom: @escaping (Int) -> Void = { _ in }
onTapChatRoom: @escaping (Int, String) -> Void = { _, _ in }
) {
self.recentChats = recentChats
self.onTapTitle = onTapTitle
@@ -24,7 +24,7 @@ struct MainHomeFollowingChatSection: View {
LazyHStack(alignment: .top, spacing: SodaSpacing.s8) {
ForEach(recentChats) { chat in
MainHomeFollowingChatItemView(chat: chat) {
onTapChatRoom(chat.roomId)
onTapChatRoom(chat.roomId, chat.chatType)
}
}
}

View File

@@ -14,7 +14,7 @@ struct MainHomeFollowingEmptyStateView: View {
Spacer()
Text(message)
.appFont(.body5)
.appFont(.body3)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)

View File

@@ -7,7 +7,7 @@ struct MainHomeFollowingView: View {
let onTapLogin: () -> Void
let onTapFollowingAll: () -> Void
let onTapChatTab: () -> Void
let onTapChatRoom: (Int) -> Void
let onTapChatRoom: (Int, String) -> Void
let onTapSchedule: (CreatorActivityType, Int) -> Void
let onTapCommunityPost: (Int) -> Void
@@ -20,7 +20,7 @@ struct MainHomeFollowingView: View {
onTapLogin: @escaping () -> Void = {},
onTapFollowingAll: @escaping () -> Void = {},
onTapChatTab: @escaping () -> Void = {},
onTapChatRoom: @escaping (Int) -> Void = { _ in },
onTapChatRoom: @escaping (Int, String) -> Void = { _, _ in },
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
) {
@@ -61,11 +61,13 @@ struct MainHomeFollowingView: View {
} else if let response = viewModel.response {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
MainHomeFollowingCreatorSection(
followingCreators: response.followingCreators,
onTapCreator: onTapCreator,
onTapAll: onTapFollowingAll
)
if !response.followingCreators.isEmpty {
MainHomeFollowingCreatorSection(
followingCreators: response.followingCreators,
onTapCreator: onTapCreator,
onTapAll: onTapFollowingAll
)
}
if !response.onAirLives.isEmpty {
MainHomeFollowingLiveSection(

View File

@@ -15,7 +15,7 @@ struct MainHomeView: View {
let onTapFollowingLogin: () -> Void
let onTapFollowingAll: () -> Void
let onTapChatTab: () -> Void
let onTapChatRoom: (Int) -> Void
let onTapChatRoom: (Int, String) -> Void
let onTapFollowingSchedule: (CreatorActivityType, Int) -> Void
@State private var selectedTab: MainHomeTab = .recommendation
@@ -125,7 +125,7 @@ struct MainHomeView_Previews: PreviewProvider {
onTapFollowingLogin: {},
onTapFollowingAll: {},
onTapChatTab: {},
onTapChatRoom: { _ in },
onTapChatRoom: { _, _ in },
onTapFollowingSchedule: { _, _ in }
)
}

View File

@@ -123,6 +123,7 @@ struct MainView: View {
.valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) }
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
.valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) }
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in
guard !isShowPlayer,
let pendingExternalNavigationAction = pendingExternalNavigationAction else {
@@ -408,6 +409,11 @@ struct MainView: View {
}
}
private func handlePendingMainChatFilter(_ filter: MainChatFilter?) {
guard filter != nil else { return }
viewModel.currentTab = .chat
}
private func handlePushAudioContentId(_ value: Int) {
guard value > 0 else { return }
let contentId = value
@@ -513,9 +519,14 @@ struct MainView: View {
viewModel.currentTab = .chat
}
private func handleFollowingChatRoomTap(roomId: Int) {
private func handleFollowingChatRoomTap(roomId: Int, chatType: String) {
guard roomId > 0 else { return }
appState.setAppStep(step: .chatRoom(id: roomId))
if chatType == "DM" {
appState.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
} else {
appState.setAppStep(step: .chatRoom(id: roomId))
}
}
private func handleFollowingScheduleTap(type: CreatorActivityType, targetId: Int) {

View File

@@ -17,9 +17,9 @@
- 여러 화면에서 재사용할 근거가 생기기 전까지 팔로잉 전용 UI는 `SodaLive/Sources/V2/Main/Home/Following/Components/**`에 둔다.
- 기존 추천/랭킹 탭의 `TargetType`, Repository, ViewModel, `ApiResponse<T>` 디코딩 패턴을 따른다.
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용한다. 비로그인 상태에서는 탭 전환과 API 호출을 하지 않고 로그인 화면으로 이동한다.
- 팔로잉 탭 전체 empty 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`를 사용한다.
- 팔로잉 탭 전체 empty 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`를 사용하고 `I18n.HomeFollowing.emptyStateMessage`로 다국어 처리한다.
- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`를 사용한다.
- 섹션별 배열이 비어 있으면 해당 섹션은 숨긴다.
- 섹션별 배열이 비어 있으면 해당 섹션은 숨긴다. 팔로잉 크리에이터 섹션도 예외 없이 `followingCreators`가 비어 있으면 `전체` 버튼까지 함께 숨긴다.
- 모든 뷰의 루트 container와 섹션 container에는 불필요한 고정 숫자 width/height를 사용하지 않는다.
- 프로필 이미지, 썸네일, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소에만 고정 크기를 사용한다.
- 외부 라이브러리는 추가하지 않는다.
@@ -183,15 +183,15 @@
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
- 각 creator item은 프로필 이미지와 닉네임을 표시한다.
- creator item 탭 시 `onTapCreator(creatorId)`를 호출한다.
- 목록 마지막에는 항상 `전체` 버튼을 표시한다.
- `전체` 버튼은 `followingCreators`비어 있어도 표시한다.
- `followingCreators`가 비어 있으면 섹션을 렌더링하지 않는다.
- 목록 마지막에는 `followingCreators`있을 때만 `전체` 버튼을 표시한다.
- `전체` 버튼 text는 `I18n.HomeFollowing.allButtonTitle`, 색상은 `Color.soda400`을 사용한다.
- `전체` 버튼 horizontal padding은 16으로 둔다.
- `전체` 버튼 높이는 creator item 높이와 동일하게 맞춘다.
- `전체` 버튼 탭 시 `onTapAll()`을 호출한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeFollowingCreatorSection|ScrollView\\(\\.horizontal|followingCreators|onTapCreator|onTapAll|allButtonTitle|Color.soda400|padding\\(\\.horizontal, 16\\)" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift`
- 기대 결과: 가로 creator 목록 마지막 `전체` 버튼 요구사항이 확인된다.
- 기대 결과: 가로 creator 목록, 비어 있을 때 섹션 숨김, 마지막 `전체` 버튼 요구사항이 확인된다.
- [x] **Task 3.2: 전체 팔로잉 목록 진입 연결**
- 대상 파일:
@@ -263,7 +263,7 @@
- 각 item은 `targetImageUrl`, `targetName`, `lastMessage`, `lastMessageAt`을 표시한다.
- `chatType == "DM"`이면 `Direct` 태그를 표시한다.
- `lastMessageAt`은 UTC 기준 문자열로 받아 상대 시간 또는 기존 Talk item과 맞는 시간 label로 표시한다.
- item 탭 시 `onTapChatRoom(roomId)`를 호출한다.
- item 탭 시 `onTapChatRoom(roomId, chatType)`를 호출한다.
- 검증 기준:
- 실행 명령: `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이 확인된다.
@@ -275,12 +275,13 @@
- 확인: `SodaLive/Sources/App/AppStep.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 작업 내용:
- `MainHomeView``onTapChatRoom: (Int) -> Void` callback을 추가한다.
- `MainHomeView``onTapChatRoom: (Int, String) -> Void` callback을 추가한다.
- `MainHomeFollowingView`까지 callback을 전달한다.
- `MainView`에서 `onTapChatRoom` `AppState.shared.setAppStep(step: .chatRoom(id: roomId))`로 구현한다.
- `MainView`에서 `chatType == "DM"`이면 `AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))`로 구현한다.
- `MainView`에서 Direct가 아니면 기존처럼 `AppState.shared.setAppStep(step: .chatRoom(id: roomId))`로 구현한다.
- 검증 기준:
- 실행 명령: `rg "onTapChatRoom|chatRoom\\(id:|ChatRoomView" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
- 기대 결과: 최근 대화 item tap 기존 채팅방 화면으로 연결된다.
- 기대 결과: 최근 대화 Direct item tap`UserCreatorChatRoomView`, Direct가 아닌 item tap은 기존 채팅방 화면으로 연결된다.
### Phase 7: 이달의 스케줄 섹션
@@ -479,7 +480,7 @@
- 작업 내용:
- 비로그인 상태에서 홈 `팔로잉` 탭을 탭하면 로그인 화면으로 이동하는지 확인한다.
- 로그인 상태에서 홈 `팔로잉` 탭 진입 시 API 호출과 섹션 표시가 동작하는지 확인한다.
- 팔로잉 크리에이터 섹션 마지막에 `전체` 버튼이 항상 표시되는지 확인한다.
- 팔로잉 크리에이터가 있으면 섹션 마지막에 `전체` 버튼이 표시되고, 팔로잉 크리에이터가 없으면 섹션과 `전체` 버튼이 표시되지 않는지 확인한다.
- `전체` 버튼 text color가 `Color.soda400`로 보이는지 확인한다.
- `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`가 타입별 UI로 표시되는지 확인한다.
- 루트/섹션 container가 고정 width/height 때문에 작은 화면에서 깨지지 않는지 확인한다.
@@ -514,6 +515,23 @@
- 실행 명령: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|CommunityPostCard" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 최근 소식 `COMMUNITY_POST` card tap이 기존 커뮤니티 게시글 상세 route로 연결된다.
### Phase 14: 팔로잉 크리에이터 빈 섹션 후속 정리
- [x] **Task 14.1: 팔로잉 크리에이터 빈 섹션과 전체 버튼 숨김**
- 대상 파일:
- 수정: `docs/20260630_메인_홈_팔로잉_탭/prd.md`
- 수정: `docs/20260630_메인_홈_팔로잉_탭/plan-task.md`
- 수정: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
- 확인: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- 전체 empty 문구가 `I18n.HomeFollowing.emptyStateMessage`로 다국어 처리되어 있음을 확인한다.
- 기존 문서의 “크리에이터가 없어도 `전체` 버튼 표시” 요구를 “크리에이터가 있을 때만 섹션과 `전체` 버튼 표시”로 갱신한다.
- `MainHomeFollowingView`에서 `response.followingCreators`가 비어 있으면 `MainHomeFollowingCreatorSection`을 렌더링하지 않는다.
- 검증 기준:
- 실행 명령: `rg "emptyStateMessage|팔로잉 소식이 아직 없어요|No following updates yet|フォロー中の新着情報" SodaLive/Sources/I18n/I18n.swift`
- 실행 명령: `rg "if !response\\.followingCreators\\.isEmpty|MainHomeFollowingCreatorSection" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
- 기대 결과: empty 문구의 ko/en/ja 다국어 처리와 팔로잉 크리에이터 빈 배열 시 섹션 렌더링 차단이 확인된다.
## 검증 기록
- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다.
@@ -609,3 +627,8 @@
- 2026-07-12: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/MainHomeView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPost" build`는 로컬에 team `3A8QGPMRXM`용 iOS Development signing certificate이 없어 코드 서명 단계에서 실패했다.
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination "generic/platform=iOS Simulator" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPostSimulator" CODE_SIGNING_ALLOWED=NO build`는 의존성 컴파일 중 600초 제한으로 `BUILD INTERRUPTED`되어 완료 여부를 확인하지 못했다.
- 2026-07-13: 사용자 요청에 따라 전체 empty 문구가 `I18n.HomeFollowing.emptyStateMessage`로 ko/en/ja 다국어 처리되어 있음을 `rg "emptyStateMessage|팔로잉 소식이 아직 없어요|No following updates yet|フォロー中の新着情報" SodaLive/Sources/I18n/I18n.swift`로 확인했다.
- 2026-07-13: `MainHomeFollowingView`에서 `response.followingCreators`가 비어 있으면 `MainHomeFollowingCreatorSection`을 렌더링하지 않도록 수정해, 팔로잉 크리에이터가 없을 때 상단 `전체` 버튼도 표시되지 않게 했다.
- 2026-07-13: PRD와 plan-task의 기존 “크리에이터가 없어도 `전체` 버튼 표시” 요구사항을 “크리에이터가 있을 때만 섹션과 `전체` 버튼 표시”로 갱신했다.
- 2026-07-13: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
- 2026-07-13: 샌드박스 안의 `xcodebuild`는 Xcode/CoreSimulator/DerivedData/SwiftPM 캐시 접근 제한으로 실패했으나, 승인 후 샌드박스 밖에서 `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -destination generic/platform=iOS\ Simulator -derivedDataPath /private/tmp/SodaLiveDerivedDataFollowingEmpty CODE_SIGNING_ALLOWED=NO build`를 실행해 `BUILD SUCCEEDED`를 확인했다. 기존 duplicate build file/dependency scan warning과 `LiveRoomViewModel`의 unused variable warning은 남아 있으나 컴파일 오류는 없었다.

View File

@@ -26,7 +26,7 @@ Figma 전체 화면 기준 섹션 순서는 아래를 따른다.
- 기존 V2 공용 컴포넌트와 추천/랭킹 탭 패턴을 우선 재사용한다.
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용해 로그인된 사용자만 탭으로 이동한다.
- `isLoginRequired == true`이면 팔로잉 기반 콘텐츠 대신 로그인 필요 상태를 표시하고, 사용자가 로그인 화면으로 이동할 수 있어야 한다.
- 팔로잉 크리에이터 섹션의 가로 목록 마지막 item에는 항상 `전체` 버튼을 추가한다.
- 팔로잉 크리에이터가 있을 때만 팔로잉 크리에이터 섹션을 표시하고, 가로 목록 마지막 item에는 `전체` 버튼을 추가한다.
- 최근 소식은 `FollowingNewsType`별로 다른 View를 표시한다.
- 각 섹션은 이후 `plan-task.md` 작성 시 Figma Design 기준 섹션당 1개의 Phase로 나눌 수 있도록 요구사항을 분리한다.
- 뷰 전체는 반드시 필요한 경우를 제외하고 고정 숫자 width/height를 갖지 않는다.
@@ -350,8 +350,8 @@ Figma 참조:
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
- 각 item은 프로필 이미지와 크리에이터 닉네임을 표시한다.
- 크리에이터 item 탭 시 `creatorId`로 기존 크리에이터 상세 화면에 진입한다.
- 목록 마지막 item에는 항상 `전체` 버튼을 추가한다.
- `전체` 버튼은 서버 데이터가 비어 있어도 표시한다.
- `followingCreators`가 비어 있으면 팔로잉 크리에이터 섹션을 표시하지 않는다.
- 목록 마지막 item에는 `followingCreators`가 있을 때만 `전체` 버튼을 추가한다.
- `전체` 버튼의 width는 버튼이 차지하는 콘텐츠 크기 기준으로 잡고, 고정 화면 width를 강제하지 않는다.
- `전체` 버튼 height는 크리에이터 item 높이와 동일하게 표시한다.
- `전체` 버튼은 `paddingHorizontal = 16`을 적용한다.
@@ -375,12 +375,12 @@ Figma 참조:
- `recentChats`가 비어 있으면 섹션을 숨긴다.
- 섹션 타이틀은 `최근 대화`를 사용한다.
- `ChatRoomListItemResponse.roomId`를 item id와 채팅방 진입 id로 사용한다.
- `chatType == "DM"`이면 `Direct` 태그를 표시한다.
- `chatType == "DM"`이면 `Direct` 태그를 표시하고, item 탭 시 `UserCreatorChatRoomView(roomId:)` 진입 흐름을 사용한다.
- `targetName`은 채팅방 제목으로 표시한다.
- `targetImageUrl`은 채팅방 이미지로 표시한다.
- `lastMessage`는 최근 메시지 preview로 표시한다.
- `lastMessageAt`은 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 시간 label로 표시한다.
- item 탭 `roomId`로 기존 채팅방 진입 흐름을 사용한다.
- Direct 태그가 없는 item 탭 `roomId`로 기존 채팅방 진입 흐름을 사용한다.
- 채팅방 진입에 로그인 guard가 필요하면 기존 메인/채팅 탭의 guard 패턴을 재사용한다.
### 7.6 이달의 스케줄 섹션
@@ -541,7 +541,8 @@ Figma 참조:
- `isLoginRequired == false`이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다.
- 비어 있는 섹션은 표시되지 않는다.
- 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다.
- 팔로잉 크리에이터 가로 목록 마지막에는 항상 `전체` 버튼이 표시된다.
- 팔로잉 크리에이터가 있으면 가로 목록 마지막에 `전체` 버튼이 표시된다.
- 팔로잉 크리에이터가 없으면 팔로잉 크리에이터 섹션과 `전체` 버튼은 표시되지 않는다.
- `전체` 버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16, `Color.soda400` 텍스트를 사용한다.
- 최근 소식의 `CREATOR_RANKING``creatorRanking` payload로 랭킹 소식 카드가 표시된다.
- 최근 소식의 `CONTENT_RANKING``contentRanking` payload로 랭킹 소식 카드가 표시된다.

View File

@@ -84,7 +84,7 @@
- 생성: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift`
- 작업 내용:
- 상태는 loading, response, filtered `audioContents`, selectedSort, selectedTheme, page, size, hasNext, hasLoaded, error toast를 둔다.
- `displayThemes`에서 전체 항목을 맨 앞에 합성한다.
- `displayThemes`에서 서버 `themes` 조회 완료 후 전체 항목을 맨 앞에 합성한다.
- 정렬/테마 변경 시 첫 페이지부터 다시 조회한다.
- `duration`이 없는 콘텐츠는 `visibleAudioContents`에 포함하지 않는다.
- 검증 기준:

View File

@@ -74,7 +74,7 @@ data class CreatorChannelAudioThemeResponse(
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
### 5.2 Theme filter
- 서버 응답 `themes` 앞에 클라이언트 전용 전체 항목을 추가한다.
- 서버 응답 `themes` 조회가 완료된 뒤 맨 앞에 클라이언트 전용 전체 항목을 추가한다.
- 서버 응답 `themeId == nil`이면 전체가 선택된 상태여야 한다.
- 테마 선택 시 첫 페이지부터 다시 조회한다.
- 전체 선택 시 `themeId`를 보내지 않는다.

View File

@@ -142,6 +142,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
- `추천 오디오`는 2열 grid로 표시한다.
- 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다.
- 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.
- `오직 보이스온에서만!` 섹션 item은 Figma `24:6745` 기준 device width가 402 이상이면 `163x230`으로 표시하고, 402 미만이면 `deviceWidth / 402` 비율로 동일하게 축소한다.
## 9. Technical Constraints
- 신규 기능 파일은 `SodaLive/Sources/V2/Main/Content/Recommendation/**` 하위에 둔다.
@@ -161,6 +162,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
- API 응답의 구현 대상 배열이 각 Figma 섹션에 매핑된다.
- `추천 시리즈`, `#키워드의 오디오` 섹션은 구현되지 않는다.
- `오직 보이스온에서만!` 섹션은 `ic_series_original` + `img_new_only` 조합으로 `ONLY` 표시를 노출한다.
- `오직 보이스온에서만!` 섹션 item 크기는 402 이상 device width에서 `163x230`이고, 402 미만 device width에서는 같은 비율로 축소된다.
- 오디오 item 탭 시 기존 content detail guard 흐름을 통해 상세로 이동한다.
- `AudioBannerResponse.seriesId``OriginalSeriesResponse.seriesId` 탭 시 `seriesDetail`로 이동한다.
- `AudioBannerResponse.link`는 deep link 우선 처리 후, deep link가 아니면 외부 브라우저로 열린다.
@@ -177,3 +179,4 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
- 2026-07-05: 사용자 확인으로 댓글 preview bubble에는 nickname을 표시하지 않고, 배너 `link`는 deep link 우선 후 외부 브라우저로 열며, `seriesId``seriesDetail` route로 이동하도록 확정했다.
- 2026-07-08: Figma `24:6738` 기준으로 콘텐츠 탭 title bar가 홈 탭 로고/bell 구성이 아니라 좌측 고정 title `콘텐츠`와 우측 `ic_bar_cash`/`ic_bar_search`/`ic_bar_storage` 구성을 사용하도록 요구사항을 보완했다.
- 2026-07-12: 요청에 따라 `최근 댓글이 많은 오디오` 섹션 타이틀을 `크리에이터의 댓글 많은 오디오`로 변경했다.
- 2026-07-12: Figma `24:6745` 기준으로 `오직 보이스온에서만!` 섹션 item 크기를 402 이상 device width에서는 `163x230`, 402 미만 device width에서는 `deviceWidth / 402` 비율 축소로 확정했다.

View File

@@ -467,3 +467,4 @@
- 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
- 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED``syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다.
- 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
- 2026-07-12 크리에이터 채널 후속 UX 수정: 크리에이터 채널 각 탭 empty state 문구의 상단 여백을 `20`으로 조정해 중앙 배치가 아닌 상단 배치로 변경했다. 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고, tap 시 `AppState.pendingMainChatFilter(.dm)`를 통해 메인 대화 탭의 DM 필터로 이동하도록 반영했다. 타인 채널의 `DM 보내기` 및 creatorId 기반 DM 채팅방 진입은 유지했다.

View File

@@ -31,6 +31,7 @@
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
- 단, 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고 메인 대화 탭의 DM 필터로 이동한다.
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
---