Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff460485d6 | ||
|
|
0337c47b03 | ||
|
|
49adeffbca | ||
|
|
dc03592b4f | ||
|
|
edf72c88a0 | ||
|
|
36e17ae092 | ||
|
|
2707c0acde | ||
|
|
dc7a6d083b | ||
|
|
fba87f201b | ||
|
|
cf5ee85889 | ||
|
|
e486642a77 | ||
|
|
aea4e5558c |
@@ -8945,7 +8945,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -8967,7 +8967,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.56.1;
|
||||
MARKETING_VERSION = 1.56.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -8986,7 +8986,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -9008,7 +9008,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.56.1;
|
||||
MARKETING_VERSION = 1.56.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img_apply_creator_kr.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
+8
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img_apply_creator_ja.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
+8
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img_introduce_sodalive_kr.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
+8
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "img_introduce_sodalive_ja.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ic_new_talk_add.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -138,6 +138,19 @@ class AppState: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replaceCurrentAppStep(step: AppStep) {
|
||||
DispatchQueue.main.async {
|
||||
if let route = self.navigationPath.popLast() {
|
||||
self.routeStepMap.removeValue(forKey: route)
|
||||
}
|
||||
|
||||
let route = AppRoute()
|
||||
self.routeStepMap[route] = step
|
||||
self.navigationPath.append(route)
|
||||
self.appStep = step
|
||||
}
|
||||
}
|
||||
|
||||
func back() {
|
||||
DispatchQueue.main.async {
|
||||
@@ -244,6 +257,10 @@ class AppState: ObservableObject {
|
||||
pendingMainChatFilter = filter
|
||||
}
|
||||
|
||||
func requestMainChatRefresh(filter: MainChatFilter) {
|
||||
pendingMainChatFilter = filter
|
||||
}
|
||||
|
||||
func consumePendingMainChatFilter() -> MainChatFilter? {
|
||||
let filter = pendingMainChatFilter
|
||||
pendingMainChatFilter = nil
|
||||
|
||||
@@ -185,6 +185,8 @@ enum AppStep {
|
||||
|
||||
case userCreatorChatCreator(creatorId: Int)
|
||||
|
||||
case userCreatorChatRecipientSearch
|
||||
|
||||
case newCharacterAll
|
||||
|
||||
case originalWorkDetail(originalId: Int)
|
||||
|
||||
@@ -354,6 +354,9 @@ struct AppStepLayerView: View {
|
||||
case .userCreatorChatCreator(let creatorId):
|
||||
UserCreatorChatRoomView(creatorId: creatorId)
|
||||
|
||||
case .userCreatorChatRecipientSearch:
|
||||
UserCreatorChatRecipientSearchView()
|
||||
|
||||
case .newCharacterAll:
|
||||
NewCharacterListView()
|
||||
|
||||
|
||||
@@ -33,6 +33,48 @@ enum I18n {
|
||||
}
|
||||
}
|
||||
|
||||
enum UserCreatorChatRecipientSearch {
|
||||
static var searchPlaceholder: String {
|
||||
pick(ko: "팬 이름을 입력하세요", en: "Enter a fan name", ja: "ファンの名前を入力してください")
|
||||
}
|
||||
|
||||
static var sectionTitle: String {
|
||||
pick(ko: "팔로워", en: "Followers", ja: "フォロワー")
|
||||
}
|
||||
|
||||
static var searchResultLabel: String {
|
||||
pick(ko: "검색결과", en: "Search results", ja: "検索結果")
|
||||
}
|
||||
|
||||
static var emptyMessage: String {
|
||||
pick(
|
||||
ko: "사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.",
|
||||
en: "No users found.\nTry searching with another name.",
|
||||
ja: "ユーザーがいません。\n別の名前で検索してください。"
|
||||
)
|
||||
}
|
||||
|
||||
static var dialogTitle: String {
|
||||
pick(ko: "메시지 보내기", en: "Send message", ja: "メッセージを送る")
|
||||
}
|
||||
|
||||
static func dialogMessage(_ nickname: String) -> String {
|
||||
pick(
|
||||
ko: "\(nickname)에게 메시지를 보낼까요?",
|
||||
en: "Send a message to \(nickname)?",
|
||||
ja: "\(nickname)にメッセージを送りますか?"
|
||||
)
|
||||
}
|
||||
|
||||
static var sendButton: String {
|
||||
pick(ko: "보내기", en: "Send", ja: "送信")
|
||||
}
|
||||
|
||||
static var openButtonAccessibilityLabel: String {
|
||||
pick(ko: "메시지 보낼 사용자 선택", en: "Select user to message", ja: "メッセージを送るユーザーを選択")
|
||||
}
|
||||
}
|
||||
|
||||
// 채팅방(캐릭터 톡) 관련 문자열
|
||||
enum ChatRoom {
|
||||
// 잠금된 메시지 다이얼로그
|
||||
@@ -3432,6 +3474,7 @@ If you block this user, the following features will be restricted.
|
||||
}
|
||||
|
||||
enum CreatorChannelHome {
|
||||
static var channelShare: String { pick(ko: "채널 공유", en: "Share channel", ja: "チャンネルを共有") }
|
||||
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
|
||||
|
||||
@@ -16,6 +16,7 @@ struct ProfileReportMenuView: View {
|
||||
let userUnBlockAction: () -> Void
|
||||
let userReportAction: () -> Void
|
||||
let profileReportAction: () -> Void
|
||||
var channelShareAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -28,6 +29,23 @@ struct ProfileReportMenuView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 13.3) {
|
||||
if let channelShareAction = channelShareAction {
|
||||
HStack(spacing: 0) {
|
||||
Text(I18n.CreatorChannelHome.channelShare)
|
||||
.appFont(size: 13.3, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 26.7)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
isShowing = false
|
||||
channelShareAction()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(isBlockedUser ? I18n.User.unblockUserAction : I18n.User.blockUserAction)
|
||||
.appFont(size: 13.3, weight: .medium)
|
||||
|
||||
+18
-13
@@ -6,6 +6,7 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
||||
let detail: CreatorChannelCommunityPostDetailResponse
|
||||
let onTapLike: () -> Void
|
||||
let onTapPurchase: () -> Void
|
||||
let onTapCreator: () -> Void
|
||||
|
||||
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
|
||||
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||
@@ -49,22 +50,26 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 42, height: 42)
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||
.background(Color.gray800)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(detail.creatorNickname)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(detail.creatorNickname)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detail.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
Text(detail.relativeTimeText())
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onTapCreator)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
+6
-1
@@ -19,7 +19,8 @@ struct CreatorChannelCommunityPostDetailView: View {
|
||||
CreatorChannelCommunityPostDetailContentView(
|
||||
detail: detail,
|
||||
onTapLike: viewModel.toggleLike,
|
||||
onTapPurchase: viewModel.presentPurchaseDialog
|
||||
onTapPurchase: viewModel.presentPurchaseDialog,
|
||||
onTapCreator: { showCreatorChannel(creatorId: detail.creatorId) }
|
||||
)
|
||||
|
||||
if viewModel.isCommentInteractionAvailable {
|
||||
@@ -120,4 +121,8 @@ struct CreatorChannelCommunityPostDetailView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func showCreatorChannel(creatorId: Int) {
|
||||
AppState.shared.setAppStep(step: .creatorDetail(userId: creatorId))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct CreatorChannelTitleBar: View {
|
||||
let isFollow: Bool
|
||||
let isNotify: Bool
|
||||
let backgroundProgress: CGFloat
|
||||
let showsTrailingActions: Bool
|
||||
let showsCreatorActions: Bool
|
||||
let onTapBack: () -> Void
|
||||
let onTapFollow: () -> Void
|
||||
@@ -12,24 +13,28 @@ struct CreatorChannelTitleBar: View {
|
||||
let onTapNotify: () -> Void
|
||||
let onTapUnnotify: () -> Void
|
||||
let onTapMore: () -> Void
|
||||
let onTapShare: () -> Void
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
isFollow: Bool,
|
||||
isNotify: Bool,
|
||||
backgroundProgress: CGFloat,
|
||||
showsTrailingActions: Bool = true,
|
||||
showsCreatorActions: Bool = true,
|
||||
onTapBack: @escaping () -> Void,
|
||||
onTapFollow: @escaping () -> Void,
|
||||
onTapUnfollow: @escaping () -> Void,
|
||||
onTapNotify: @escaping () -> Void,
|
||||
onTapUnnotify: @escaping () -> Void,
|
||||
onTapMore: @escaping () -> Void
|
||||
onTapMore: @escaping () -> Void,
|
||||
onTapShare: @escaping () -> Void = {}
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.isFollow = isFollow
|
||||
self.isNotify = isNotify
|
||||
self.backgroundProgress = backgroundProgress
|
||||
self.showsTrailingActions = showsTrailingActions
|
||||
self.showsCreatorActions = showsCreatorActions
|
||||
self.onTapBack = onTapBack
|
||||
self.onTapFollow = onTapFollow
|
||||
@@ -37,6 +42,7 @@ struct CreatorChannelTitleBar: View {
|
||||
self.onTapNotify = onTapNotify
|
||||
self.onTapUnnotify = onTapUnnotify
|
||||
self.onTapMore = onTapMore
|
||||
self.onTapShare = onTapShare
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -56,22 +62,32 @@ struct CreatorChannelTitleBar: View {
|
||||
.opacity(backgroundProgress >= 1 ? 1 : 0)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if showsCreatorActions {
|
||||
HStack(spacing: SodaSpacing.s14) {
|
||||
followButton
|
||||
if showsTrailingActions {
|
||||
if showsCreatorActions {
|
||||
HStack(spacing: SodaSpacing.s14) {
|
||||
followButton
|
||||
|
||||
if isFollow {
|
||||
notifyButton
|
||||
if isFollow {
|
||||
notifyButton
|
||||
}
|
||||
|
||||
Button(action: onTapMore) {
|
||||
Image("ic_new_more")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button(action: onTapMore) {
|
||||
Image("ic_new_more")
|
||||
.layoutPriority(1)
|
||||
} else {
|
||||
Button(action: onTapShare) {
|
||||
Image("ic_audio_content_share")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(I18n.CreatorChannelHome.channelShare)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
|
||||
@@ -192,7 +192,7 @@ struct CreatorChannelView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.response != nil && isShowReportMenu {
|
||||
if let creator = viewModel.response?.creator, isShowReportMenu {
|
||||
VStack(spacing: 0) {
|
||||
ProfileReportMenuView(
|
||||
isShowing: $isShowReportMenu,
|
||||
@@ -200,7 +200,10 @@ struct CreatorChannelView: View {
|
||||
userBlockAction: { isShowUserBlockConfirm = true },
|
||||
userUnBlockAction: {},
|
||||
userReportAction: { isShowUserReport = true },
|
||||
profileReportAction: { isShowProfileReport = true }
|
||||
profileReportAction: { isShowProfileReport = true },
|
||||
channelShareAction: {
|
||||
viewModel.shareChannel(creatorId: creator.creatorId, nickname: creator.nickname)
|
||||
}
|
||||
)
|
||||
|
||||
if proxy.safeAreaInsets.bottom > 0 {
|
||||
@@ -246,6 +249,13 @@ struct CreatorChannelView: View {
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.sheet(
|
||||
isPresented: $viewModel.isShowShareView,
|
||||
onDismiss: { viewModel.shareMessage = "" },
|
||||
content: {
|
||||
ActivityViewController(activityItems: [viewModel.shareMessage])
|
||||
}
|
||||
)
|
||||
.fullScreenCover(isPresented: $isShowAuthView) {
|
||||
authView
|
||||
}
|
||||
@@ -270,6 +280,7 @@ struct CreatorChannelView: View {
|
||||
isFollow: viewModel.response?.creator.isFollow ?? false,
|
||||
isNotify: viewModel.response?.creator.isNotify ?? false,
|
||||
backgroundProgress: backgroundProgress,
|
||||
showsTrailingActions: viewModel.response != nil,
|
||||
showsCreatorActions: !isOwnCreatorChannel,
|
||||
onTapBack: {
|
||||
dismiss()
|
||||
@@ -288,6 +299,11 @@ struct CreatorChannelView: View {
|
||||
},
|
||||
onTapMore: {
|
||||
isShowReportMenu = true
|
||||
},
|
||||
onTapShare: {
|
||||
if let creator = viewModel.response?.creator {
|
||||
viewModel.shareChannel(creatorId: creator.creatorId, nickname: creator.nickname)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,28 @@ final class CreatorChannelViewModel: ObservableObject {
|
||||
@Published var isApiFailedPlaceholderVisible = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isShowShareView = false
|
||||
@Published var shareMessage = ""
|
||||
|
||||
func shareChannel(creatorId: Int, nickname: String) {
|
||||
isLoading = true
|
||||
|
||||
let params = [
|
||||
"af_dp": "voiceon://",
|
||||
"deep_link_value": "channel",
|
||||
"deep_link_sub5": "\(creatorId)"
|
||||
]
|
||||
|
||||
if let shareUrl = createOneLinkUrlWithURLComponents(params: params) {
|
||||
self.shareMessage = I18n.MemberChannel.shareChannelMessage(nickname) + "\n\(shareUrl)"
|
||||
self.isShowShareView = true
|
||||
} else {
|
||||
self.errorMessage = I18n.MemberChannel.shareLinkCreateFailed
|
||||
self.isShowPopup = true
|
||||
}
|
||||
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
func fetchHome(creatorId: Int) {
|
||||
isLoading = true
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct MainChatView: View {
|
||||
let onTapCanCharge: () -> Void
|
||||
let onTapSearch: () -> Void
|
||||
|
||||
@StateObject private var viewModel = MainChatViewModel()
|
||||
@StateObject private var appState = AppState.shared
|
||||
@@ -13,9 +12,6 @@ struct MainChatView: View {
|
||||
HStack(spacing: SodaSpacing.s14) {
|
||||
Image("ic_bar_cash")
|
||||
.onTapGesture { onTapCanCharge() }
|
||||
|
||||
Image("ic_bar_search")
|
||||
.onTapGesture { onTapSearch() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,13 +120,13 @@ struct MainChatView: View {
|
||||
|
||||
private func applyPendingFilterIfNeeded() -> Bool {
|
||||
guard let filter = appState.consumePendingMainChatFilter() else { return false }
|
||||
viewModel.applyFilter(filter)
|
||||
viewModel.fetchFirstPage(filter: filter)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct MainChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainChatView(onTapCanCharge: {}, onTapSearch: {})
|
||||
MainChatView(onTapCanCharge: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct UserCreatorCreateRoomRequest: Encodable {
|
||||
let creatorId: Int
|
||||
let recipientId: Int?
|
||||
let creatorId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recipientId
|
||||
case creatorId
|
||||
}
|
||||
|
||||
init(recipientId: Int? = nil, creatorId: Int? = nil) {
|
||||
self.recipientId = recipientId
|
||||
self.creatorId = creatorId
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(recipientId, forKey: .recipientId)
|
||||
try container.encodeIfPresent(creatorId, forKey: .creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCreatorCreateRoomResponse: Decodable {
|
||||
|
||||
+4
@@ -10,6 +10,10 @@ final class UserCreatorChatRepository {
|
||||
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId)))
|
||||
}
|
||||
|
||||
func createRoom(recipientId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(recipientId: recipientId)))
|
||||
}
|
||||
|
||||
func openRoom(roomId: Int, limit: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.openRoom(roomId: roomId, limit: limit))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
struct UserCreatorChatRoomView: View {
|
||||
@StateObject private var viewModel = UserCreatorChatRoomViewModel()
|
||||
@@ -200,3 +201,392 @@ struct UserCreatorChatRoomView_Previews: PreviewProvider {
|
||||
UserCreatorChatRoomView(roomId: 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCreatorChatRecipientItem: Identifiable, Equatable {
|
||||
let id: Int
|
||||
let nickname: String
|
||||
let profileImageUrl: String
|
||||
|
||||
init(follower item: GetFollowerListResponseItem) {
|
||||
self.id = item.userId
|
||||
self.nickname = item.nickname
|
||||
self.profileImageUrl = item.profileImage
|
||||
}
|
||||
|
||||
init(search item: GetRoomDetailUser) {
|
||||
self.id = item.id
|
||||
self.nickname = item.nickname
|
||||
self.profileImageUrl = item.profileImageUrl
|
||||
}
|
||||
}
|
||||
|
||||
final class UserCreatorChatRecipientSearchViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var searchText = ""
|
||||
@Published private(set) var items = [UserCreatorChatRecipientItem]()
|
||||
@Published private(set) var isSearchMode = false
|
||||
@Published private(set) var isCreatingRoom = false
|
||||
|
||||
private let followRepository = ExplorerRepository()
|
||||
private let userRepository = UserRepository()
|
||||
private let chatRepository = UserCreatorChatRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private var searchSubscription: AnyCancellable?
|
||||
private var followingItems = [UserCreatorChatRecipientItem]()
|
||||
private var page = 1
|
||||
private var isLast = false
|
||||
private let pageSize = 10
|
||||
private var isFetchingFollowing = false
|
||||
private var isSearching = false
|
||||
private var didCompleteSearch = false
|
||||
private var activeSearchQuery: String?
|
||||
|
||||
var shouldShowEmptySearchResult: Bool {
|
||||
isSearchMode && didCompleteSearch && !isSearching && items.isEmpty
|
||||
}
|
||||
|
||||
init() {
|
||||
$searchText
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.removeDuplicates()
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] query in
|
||||
self?.handleSearchQuery(query)
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func fetchFirstPageIfNeeded() {
|
||||
guard followingItems.isEmpty else { return }
|
||||
fetchFollowing()
|
||||
}
|
||||
|
||||
func fetchNextPageIfNeeded(currentItem item: UserCreatorChatRecipientItem) {
|
||||
guard !isSearchMode, item.id == followingItems.last?.id else { return }
|
||||
fetchFollowing()
|
||||
}
|
||||
|
||||
func createRoom(recipientId: Int) {
|
||||
guard recipientId > 0, !isCreatingRoom else { return }
|
||||
isCreatingRoom = true
|
||||
updateLoading()
|
||||
|
||||
chatRepository.createRoom(recipientId: recipientId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyCreateRoomFailure(message: nil)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorCreateRoomResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success, data.roomId > 0 {
|
||||
self.isCreatingRoom = false
|
||||
self.updateLoading()
|
||||
AppState.shared.requestMainChatRefresh(filter: .dm)
|
||||
AppState.shared.replaceCurrentAppStep(step: .userCreatorChatRoom(roomId: data.roomId))
|
||||
} else {
|
||||
self.applyCreateRoomFailure(message: decoded.message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyCreateRoomFailure(message: nil)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func handleSearchQuery(_ query: String) {
|
||||
guard query.count >= 2 else {
|
||||
searchSubscription?.cancel()
|
||||
activeSearchQuery = nil
|
||||
isSearching = false
|
||||
didCompleteSearch = false
|
||||
isSearchMode = false
|
||||
items = followingItems
|
||||
updateLoading()
|
||||
return
|
||||
}
|
||||
|
||||
search(query: query)
|
||||
}
|
||||
|
||||
private func fetchFollowing() {
|
||||
guard !isLast, !isFetchingFollowing else { return }
|
||||
isFetchingFollowing = true
|
||||
updateLoading()
|
||||
|
||||
followRepository.getFollowerList(userId: UserDefaults.int(forKey: .userId), page: page, size: pageSize)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.isFetchingFollowing = false
|
||||
self.applyFailure(message: nil)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
self.isFetchingFollowing = false
|
||||
self.updateLoading()
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<GetFollowerListResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
let mappedItems = data.items.map(UserCreatorChatRecipientItem.init(follower:))
|
||||
if self.page == 1 {
|
||||
self.followingItems.removeAll()
|
||||
}
|
||||
self.followingItems.append(contentsOf: mappedItems)
|
||||
self.items = self.isSearchMode ? self.items : self.followingItems
|
||||
if data.items.isEmpty {
|
||||
self.isLast = true
|
||||
} else {
|
||||
self.page += 1
|
||||
}
|
||||
} else {
|
||||
self.applyFailure(message: decoded.message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailure(message: nil)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func search(query: String) {
|
||||
isSearchMode = true
|
||||
isSearching = true
|
||||
didCompleteSearch = false
|
||||
items.removeAll()
|
||||
updateLoading()
|
||||
searchSubscription?.cancel()
|
||||
activeSearchQuery = query
|
||||
|
||||
searchSubscription = userRepository.searchUser(nickname: query)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
guard self.activeSearchQuery == query else { return }
|
||||
self.isSearching = false
|
||||
self.applyFailure(message: nil)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard self.activeSearchQuery == query else { return }
|
||||
self.isSearching = false
|
||||
self.updateLoading()
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<[GetRoomDetailUser]>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.items = data.map(UserCreatorChatRecipientItem.init(search:))
|
||||
self.didCompleteSearch = true
|
||||
} else {
|
||||
self.applyFailure(message: decoded.message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailure(message: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFailure(message: String?) {
|
||||
updateLoading()
|
||||
errorMessage = message ?? I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
}
|
||||
|
||||
private func applyCreateRoomFailure(message: String?) {
|
||||
isCreatingRoom = false
|
||||
applyFailure(message: message)
|
||||
}
|
||||
|
||||
private func updateLoading() {
|
||||
isLoading = isFetchingFollowing || isSearching || isCreatingRoom
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCreatorChatRecipientSearchView: View {
|
||||
@StateObject private var viewModel = UserCreatorChatRecipientSearchViewModel()
|
||||
@State private var selectedRecipient: UserCreatorChatRecipientItem?
|
||||
|
||||
var body: some View {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
searchBar
|
||||
.padding(.top, SodaSpacing.s8)
|
||||
|
||||
listContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture().onEnded { hideKeyboard() })
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.onAppear { viewModel.fetchFirstPageIfNeeded() }
|
||||
.sodaToast(
|
||||
isPresented: $viewModel.isShowPopup,
|
||||
message: viewModel.errorMessage,
|
||||
autohideIn: 2
|
||||
)
|
||||
|
||||
if let selectedRecipient {
|
||||
confirmationDialog(for: selectedRecipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
Image("ic_back")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.onTapGesture { AppState.shared.back() }
|
||||
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(Color.gray500)
|
||||
|
||||
TextField(I18n.UserCreatorChatRecipientSearch.searchPlaceholder, text: $viewModel.searchText)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.accentColor(Color.soda400)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s12)
|
||||
.frame(height: 42)
|
||||
.background(Color.gray900)
|
||||
.cornerRadius(21)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.frame(width: screenSize().width, height: 54)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listContent: some View {
|
||||
if viewModel.shouldShowEmptySearchResult {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sectionHeader
|
||||
emptySearchContent
|
||||
}
|
||||
.padding(.top, SodaSpacing.s8)
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.isSearchMode || !viewModel.items.isEmpty {
|
||||
sectionHeader
|
||||
}
|
||||
|
||||
ForEach(viewModel.items) { item in
|
||||
UserCreatorChatRecipientRow(item: item) {
|
||||
hideKeyboard()
|
||||
selectedRecipient = item
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.fetchNextPageIfNeeded(currentItem: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, SodaSpacing.s8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionHeader: some View {
|
||||
if viewModel.isSearchMode {
|
||||
HStack(spacing: SodaSpacing.s4) {
|
||||
Text(I18n.UserCreatorChatRecipientSearch.searchResultLabel)
|
||||
.appFont(.body2)
|
||||
.foregroundColor(.white)
|
||||
Text("\(viewModel.items.count)")
|
||||
.appFont(.body2)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
.appFont(size: 14, weight: .bold)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.frame(maxWidth: .infinity, minHeight: 40, alignment: .leading)
|
||||
} else {
|
||||
Text(I18n.UserCreatorChatRecipientSearch.sectionTitle)
|
||||
.appFont(.body1)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.frame(height: 40, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptySearchContent: some View {
|
||||
Text(I18n.UserCreatorChatRecipientSearch.emptyMessage)
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(Color.gray500)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func confirmationDialog(for item: UserCreatorChatRecipientItem) -> some View {
|
||||
SodaV2ActionModal(
|
||||
title: I18n.UserCreatorChatRecipientSearch.dialogTitle,
|
||||
message: I18n.UserCreatorChatRecipientSearch.dialogMessage(item.nickname),
|
||||
button1: SodaV2ActionModalButton(
|
||||
label: I18n.UserCreatorChatRecipientSearch.sendButton,
|
||||
action: {
|
||||
selectedRecipient = nil
|
||||
viewModel.createRoom(recipientId: item.id)
|
||||
}
|
||||
),
|
||||
button2: SodaV2ActionModalButton(
|
||||
label: I18n.Common.cancel,
|
||||
action: { selectedRecipient = nil }
|
||||
),
|
||||
onDimmedTap: { selectedRecipient = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UserCreatorChatRecipientRow: View {
|
||||
let item: UserCreatorChatRecipientItem
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: SodaSpacing.s12) {
|
||||
DownsampledKFImage(
|
||||
url: URL(string: item.profileImageUrl),
|
||||
size: CGSize(width: 42, height: 42)
|
||||
)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text(item.nickname)
|
||||
.appFont(size: 14, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.frame(height: 66)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,26 @@ struct MainView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if shouldShowChatRecipientSearchButton {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
CreatorChannelFloatingIconButton(
|
||||
imageName: "ic_new_talk_add",
|
||||
backgroundColor: Color.soda400,
|
||||
accessibilityLabel: I18n.UserCreatorChatRecipientSearch.openButtonAccessibilityLabel,
|
||||
action: showUserCreatorChatRecipientSearch
|
||||
)
|
||||
.padding(.trailing, SodaSpacing.s14)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.padding(.bottom, chatRecipientSearchButtonBottomPadding)
|
||||
}
|
||||
|
||||
if isShowPlayer {
|
||||
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
|
||||
}
|
||||
@@ -161,6 +181,10 @@ struct MainView: View {
|
||||
UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .home
|
||||
}
|
||||
|
||||
private var shouldShowChatRecipientSearchButton: Bool {
|
||||
UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .chat
|
||||
}
|
||||
|
||||
private var creatorActionMenuBottomPadding: CGFloat {
|
||||
var bottomPadding = mainTabBarHeight + SodaSpacing.s14
|
||||
if contentPlayerPlayManager.isShowingMiniPlayer {
|
||||
@@ -172,6 +196,10 @@ struct MainView: View {
|
||||
return bottomPadding
|
||||
}
|
||||
|
||||
private var chatRecipientSearchButtonBottomPadding: CGFloat {
|
||||
creatorActionMenuBottomPadding
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
switch viewModel.currentTab {
|
||||
@@ -206,10 +234,7 @@ struct MainView: View {
|
||||
onTapNewAndHotAll: handleContentNewAndHotAllTap
|
||||
)
|
||||
case .chat:
|
||||
MainChatView(
|
||||
onTapCanCharge: handleHomeCanChargeTap,
|
||||
onTapSearch: handleHomeSearchTap
|
||||
)
|
||||
MainChatView(onTapCanCharge: handleHomeCanChargeTap)
|
||||
case .my:
|
||||
MyPageView()
|
||||
}
|
||||
@@ -686,6 +711,10 @@ struct MainView: View {
|
||||
appState.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: handleMainCreateLiveSuccess))
|
||||
}
|
||||
|
||||
private func showUserCreatorChatRecipientSearch() {
|
||||
appState.setAppStep(step: .userCreatorChatRecipientSearch)
|
||||
}
|
||||
|
||||
private func handleMainCreateLiveSuccess(response: CreateLiveRoomResponse) {
|
||||
liveViewModel.getLiveMain()
|
||||
if let _ = response.channelName {
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# 크리에이터 DM 리스너 선택 Goal 실행형 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 검증 중 |
|
||||
| 작성일 | `2026-09-14` |
|
||||
| 요구사항 기준 | `docs/20260914_크리에이터_DM_리스너_선택/prd.md` |
|
||||
| API 기준 | `docs/20260914_크리에이터_DM_리스너_선택/prd.md` §11 |
|
||||
| 현재 Phase | Phase 3 검증 |
|
||||
| 현재 활성 Goal | `P3-GATE` |
|
||||
|
||||
## 목표
|
||||
|
||||
크리에이터가 메인 대화 탭에서 팔로우 리스트 또는 닉네임 검색으로 리스너를 선택하고 기존 유저-크리에이터 DM 방 생성 API로 DM 방을 시작한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||
| 2 | 완료 | `5/5` | 없음 | 수동 네트워크 QA 필요 |
|
||||
| 3 | 진행 중 | `0/1` | `P3-GATE` | 수동 QA 미수행 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 메인 대화 탭 우측 하단 크리에이터 전용 `+` 플로팅 버튼.
|
||||
- 신규 리스너 선택 화면과 라우팅.
|
||||
- 기존 팔로우 전체 리스트 API 재사용.
|
||||
- `GET /member/search?nickname=` 2글자 이상, 500ms debounce 검색.
|
||||
- 검색 empty 문구 다국어 처리.
|
||||
- `SodaV2ActionModal` 기반 확인 팝업.
|
||||
- 기존 DM 방 생성 API의 request model 확장과 `recipientId` 기반 호출.
|
||||
- 성공 시 `AppStep.userCreatorChatRoom(roomId:)` 진입.
|
||||
|
||||
### 제외
|
||||
|
||||
- 채팅방 메시지 UI와 WebSocket 전송 로직 변경.
|
||||
- 새 공통 모달 컴포넌트 작성.
|
||||
- 신규 dependency 추가.
|
||||
- 팔로우 전체 리스트 API 서버 계약 변경.
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: SwiftUI, Combine, Moya, 기존 `AppState`/`AppStep` 라우팅.
|
||||
- UI 배치: Figma `2372:23496`, 팝업 `2372:23521` 기준.
|
||||
- 공통 컴포넌트: `SodaV2ActionModal`, 기존 색상·폰트·spacing token 재사용.
|
||||
- API 재사용: `FollowCreatorRepository`, `UserRepository.searchUser`, `UserCreatorChatRepository` 기준.
|
||||
- request model: `UserCreatorCreateRoomRequest`는 `recipientId`와 `creatorId`를 optional로 표현할 수 있어야 한다.
|
||||
- 검증: 테스트 번들 타깃이 확인되지 않으므로 focused 자동 테스트 대신 빌드와 수동 QA 기준을 명시한다.
|
||||
|
||||
## Task TDD 작성 규칙
|
||||
|
||||
현재 저장소는 `docs/agent-guides/build-test-verification.md` 기준 테스트 번들 타깃이 확인되지 않는다. 구현 Task는 가능한 경우 작은 ViewModel 단위 테스트를 우선 검토하되, 테스트 타깃이 없으면 `TDD 예외 사유`와 `대체 검증 방법`을 Task에 기록한다.
|
||||
|
||||
## Phase 1 — 라우팅과 진입점
|
||||
|
||||
**Phase 결과:** 크리에이터가 메인 대화 탭에서 신규 리스너 선택 화면에 진입할 수 있다.
|
||||
|
||||
**선행조건:** PRD 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-T2` 완료와 빌드 통과.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 메인 대화 탭 플로팅 버튼
|
||||
|
||||
**Goal 실행 `P1-T1`:** 크리에이터 계정의 메인 대화 탭 우측 하단에 신규 DM 시작 `+` 버튼을 표시한다.
|
||||
|
||||
- **시작 조건:** `CDM-ENTRY-001`, `CDM-ENTRY-002` 확정.
|
||||
- **완료 증거:** 크리에이터 role에서만 버튼 표시, 비크리에이터에서는 미표시.
|
||||
- **범위 밖:** 새 화면 내부 리스트와 API 호출.
|
||||
- **TDD 예외 사유:** UI 조건 렌더링 작업이며 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** `UserDefaults.role`이 `MemberRole.CREATOR.rawValue`일 때와 아닐 때의 수동 QA, Debug 빌드.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||
- Reuse: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelFloatingIconButton.swift` 또는 기존 홈 플로팅 버튼 컴포넌트
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `MemberRole.CREATOR.rawValue`, `MainTab.chat`
|
||||
- Produces: `MainView` 채팅 탭 overlay에서 신규 DM 시작 액션
|
||||
|
||||
- [x] 버튼 재사용 후보를 확인하고 새 공통 abstraction 없이 가장 가까운 기존 플로팅 버튼을 사용한다.
|
||||
- [x] 기존 `MainChatView` 수정 없이 `MainView` overlay에서 버튼을 표시한다.
|
||||
- [x] `MainView`에서 현재 탭이 `.chat`이고 role이 creator일 때만 버튼을 표시한다.
|
||||
- [x] 버튼 bottom padding은 main tab bar와 mini player가 가리지 않도록 기존 `creatorActionMenuBottomPadding` 계산 방식을 따른다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
#### Task 1.2 AppStep 라우팅 추가
|
||||
|
||||
**Goal 실행 `P1-T2`:** 신규 리스너 선택 화면으로 이동할 수 있는 AppStep 라우팅을 추가한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** `+` 버튼 탭 시 신규 화면이 push되고 뒤로가기로 복귀 가능.
|
||||
- **범위 밖:** 화면 내부 API 연동.
|
||||
- **TDD 예외 사유:** 라우팅 연결 작업이며 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** 수동 내비게이션 QA와 Debug 빌드.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/App/AppStep.swift`
|
||||
- Modify: `SodaLive/Sources/ContentView.swift`
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `AppStep.userCreatorChatRecipientSearch`
|
||||
- Consumes: `AppState.shared.setAppStep(step:)`
|
||||
|
||||
- [x] `AppStep`에 신규 case를 추가한다.
|
||||
- [x] `AppStepLayerView`에서 신규 case를 새 화면으로 매핑한다.
|
||||
- [x] `MainView`의 채팅 탭 `+` 버튼 액션에서 신규 AppStep을 호출한다.
|
||||
- [x] 새 화면은 최소 shell만 만들고 내부 API는 Phase 2에서 연결한다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
## Phase 2 — 리스트, 검색, 확인, 방 생성
|
||||
|
||||
**Phase 결과:** 크리에이터가 리스너를 선택하고 DM 방 생성 후 채팅방에 진입한다.
|
||||
|
||||
**선행조건:** Phase 1 완료.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`~`P2-T5` 완료와 Phase 2 Gate 통과.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 2.1 화면 UI shell과 다국어 키
|
||||
|
||||
**Goal 실행 `P2-T1`:** Figma 기준 신규 화면 shell과 필요한 다국어 키를 추가한다.
|
||||
|
||||
- **시작 조건:** `P1-T2` 완료, Figma `2372:23496`, `2372:23521` 확인.
|
||||
- **완료 증거:** 검색 바, 섹션 타이틀, 리스트 검색 안내 문구, empty 영역, 팝업 문구 키가 존재한다.
|
||||
- **범위 밖:** 실제 API 호출과 방 생성.
|
||||
- **TDD 예외 사유:** UI shell과 resource 추가 작업이며 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** SwiftGen 또는 프로젝트 string 생성 절차가 있으면 실행, 없으면 빌드로 `I18n` 참조 성공 확인.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
- Modify: localization resource files used by `I18n`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.searchPlaceholder`
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.sectionTitle`
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.emptyMessage`
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.dialogTitle`
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.dialogMessage(_ nickname: String)`
|
||||
- Produces: `I18n.UserCreatorChatRecipientSearch.sendButton`
|
||||
|
||||
- [x] 검색 안내 문구 `팬 이름을 입력하세요`를 다국어 키로 추가한다.
|
||||
- [x] 섹션 타이틀 `팔로워`를 다국어 키로 추가한다.
|
||||
- [x] empty 문구 `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`를 다국어 키로 추가한다.
|
||||
- [x] 팝업 제목과 본문, 보내기 문구를 다국어 키로 추가한다.
|
||||
- [x] 화면 shell이 Figma 기준 검은 배경, 뒤로가기, 검색 바, 리스트 영역을 표시하도록 작성한다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
#### Task 2.2 초기 팔로우 전체 리스트 연동
|
||||
|
||||
**Goal 실행 `P2-T2`:** 화면 진입 시 기존 팔로우 전체 리스트 API로 초기 목록을 표시한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** 화면 최초 진입 시 `/live/recommend/following/channel/all/list`가 기존 pagination 정책으로 호출된다.
|
||||
- **범위 밖:** 검색 API와 방 생성 API.
|
||||
- **TDD 예외 사유:** network ViewModel 작업이나 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** Charles/Xcode network log 또는 Moya stub 가능 시 요청 path·query 확인, Debug 빌드.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
- Reuse: `SodaLive/Sources/Follow/FollowCreatorRepository.swift`
|
||||
- Reuse: `SodaLive/Sources/Follow/GetCreatorFollowingAllListResponse.swift`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `FollowCreatorRepository.getFollowedCreatorAllList(page:size:)`
|
||||
- Produces: `UserCreatorChatRecipientItem(id:nickname:profileImageUrl:)`
|
||||
|
||||
- [x] ViewModel에 `isLoading`, `errorMessage`, `isShowPopup`, `items`, `page`, `isLast` 상태를 추가한다.
|
||||
- [x] 최초 진입 시 `getFollowedCreatorAllList(page: 1, size: 10)`을 호출한다.
|
||||
- [x] API response의 `creatorId`, `nickname`, `profileImageUrl`을 화면 공통 item으로 매핑한다.
|
||||
- [x] 리스트 마지막 아이템 노출 시 다음 page를 호출한다.
|
||||
- [x] 오류는 기존 toast 방식으로 표시한다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
#### Task 2.3 유저 검색과 debounce
|
||||
|
||||
**Goal 실행 `P2-T3`:** 2글자 이상 검색어에 대해 500ms debounce 후 유저 검색 API를 호출하고 empty 상태를 표시한다.
|
||||
|
||||
- **시작 조건:** `P2-T2` 완료.
|
||||
- **완료 증거:** 0~1글자는 검색 요청 0회, 2글자 이상은 마지막 입력 500ms 후 요청 1회.
|
||||
- **범위 밖:** 유저 선택 후 방 생성.
|
||||
- **TDD 예외 사유:** 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** 수동 입력 QA와 network 요청 횟수 확인.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
- Reuse: `SodaLive/Sources/User/UserRepository.swift`
|
||||
- Reuse: `SodaLive/Sources/User/UserApi.swift`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `UserRepository.searchUser(nickname:)`
|
||||
- Produces: search mode item list and empty state flag
|
||||
|
||||
- [x] 검색어 상태를 ViewModel로 전달한다.
|
||||
- [x] trim 결과가 2글자 미만이면 pending debounce를 취소하고 초기 팔로우 리스트 모드로 돌아간다.
|
||||
- [x] trim 결과가 2글자 이상이면 500ms debounce 후 `UserRepository.searchUser(nickname:)`를 호출한다.
|
||||
- [x] 검색어가 바뀌면 이전 debounce 작업과 이전 검색 결과 적용을 취소하거나 무시한다.
|
||||
- [x] 검색 결과 0건이면 중앙 empty 문구를 표시한다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
#### Task 2.4 유저 선택 확인 팝업
|
||||
|
||||
**Goal 실행 `P2-T4`:** 리스트 유저 터치 시 V2 공통 팝업으로 DM 보내기 확인을 받는다.
|
||||
|
||||
- **시작 조건:** `P2-T2` 또는 `P2-T3` 완료.
|
||||
- **완료 증거:** 유저 터치 시 API 호출 없이 팝업이 뜨고, 취소 또는 dimmed tap으로 닫힌다.
|
||||
- **범위 밖:** 보내기 이후 방 생성 API 구현.
|
||||
- **TDD 예외 사유:** UI interaction 작업이며 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** 수동 QA와 Debug 빌드.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
- Reuse: `SodaLive/Sources/V2/Component/Modal/SodaV2ActionModal.swift`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `UserCreatorChatRecipientItem`
|
||||
- Produces: selected recipient state
|
||||
|
||||
- [x] 리스트 아이템 tap handler에서 selected recipient를 저장한다.
|
||||
- [x] `SodaV2ActionModal` title은 `메시지 보내기` 키를 사용한다.
|
||||
- [x] message는 선택 유저 닉네임을 넣어 `{{사용자이름}}에게 메시지를 보낼까요?` 형식으로 표시한다.
|
||||
- [x] 취소와 dimmed tap은 selected recipient를 초기화한다.
|
||||
- [x] 보내기 버튼은 `P2-T5`의 방 생성 액션을 호출할 수 있도록 연결 지점만 만든다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
#### Task 2.5 recipientId 기반 DM 방 생성
|
||||
|
||||
**Goal 실행 `P2-T5`:** 확인 팝업의 보내기에서 선택한 리스너 ID로 기존 DM 방 생성 API를 호출하고 채팅방에 진입한다.
|
||||
|
||||
- **시작 조건:** `P2-T4` 완료.
|
||||
- **완료 증거:** request body가 `recipientId`를 포함하고, 성공 response의 `roomId`로 `userCreatorChatRoom`에 진입한다.
|
||||
- **범위 밖:** 채팅방 내부 메시지 전송 UI 변경.
|
||||
- **TDD 예외 사유:** API integration 작업이나 현재 테스트 타깃이 확인되지 않는다.
|
||||
- **대체 검증 방법:** network request body 확인, 성공 시 화면 이동 수동 QA, 기존 크리에이터 채널 DM 시작 회귀 확인.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift`
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift`
|
||||
- Modify: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||
- Verify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `UserCreatorCreateRoomRequest(recipientId: Int? = nil, creatorId: Int? = nil)`
|
||||
- Produces: `UserCreatorChatRepository.createRoom(recipientId: Int)`
|
||||
- Preserves: `UserCreatorChatRepository.createRoom(creatorId: Int)` existing caller behavior
|
||||
|
||||
- [x] `UserCreatorCreateRoomRequest`를 `recipientId`와 `creatorId` optional request로 변경한다.
|
||||
- [x] 기존 `createRoom(creatorId:)`는 `creatorId`만 보내도록 유지한다.
|
||||
- [x] 신규 `createRoom(recipientId:)`를 추가하고 `recipientId`만 보낸다.
|
||||
- [x] 보내기 탭 중 중복 제출을 막기 위해 loading 상태 동안 버튼 액션을 무시한다.
|
||||
- [x] 성공 response의 `roomId`로 `AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))`를 호출한다.
|
||||
- [x] 실패 시 서버 message 또는 `I18n.Common.commonError`를 toast로 표시한다.
|
||||
- [x] 기존 크리에이터 채널의 `AppStep.userCreatorChatCreator(creatorId:)` 흐름이 `creatorId` request를 유지하는지 확인한다.
|
||||
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 성공을 확인한다.
|
||||
|
||||
## Phase 3 — 검증 Gate
|
||||
|
||||
**Phase 결과:** 신규 DM 시작 흐름과 기존 DM 시작 흐름이 모두 동작한다.
|
||||
|
||||
**선행조건:** Phase 2 전체 완료.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 3 Gate
|
||||
|
||||
**Goal 실행 `P3-GATE`:** 요구사항 전체와 회귀 위험을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1`~`P2-T5` 완료.
|
||||
- **완료 증거:** 아래 명령과 수동 QA 결과를 Progress에 기록.
|
||||
- **범위 밖:** 실패와 무관한 UI 리팩터링.
|
||||
|
||||
```bash
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
**Expected:** exit code 0.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [ ] 크리에이터 계정: 메인 대화 탭 우측 하단 `+` 버튼 표시.
|
||||
- [ ] 비크리에이터 계정: 메인 대화 탭 우측 하단 `+` 버튼 미표시.
|
||||
- [ ] 신규 화면 최초 진입: `/live/recommend/following/channel/all/list?page=0&size=10&timezone=...` 호출.
|
||||
- [ ] 검색 1글자: `/member/search` 요청 0회.
|
||||
- [ ] 검색 2글자 이상 연속 입력: 마지막 입력 500ms 후 `/member/search?nickname=` 요청 1회.
|
||||
- [ ] 검색 결과 없음: 중앙 empty 문구 표시.
|
||||
- [ ] 유저 선택: `SodaV2ActionModal` 표시, 취소와 dimmed tap 닫힘.
|
||||
- [ ] 보내기: `/api/v2/user-creator-chat/rooms/create` request body에 `recipientId`만 포함.
|
||||
- [ ] 방 생성 성공: `UserCreatorChatRoomView`로 이동.
|
||||
- [ ] 기존 크리에이터 채널 DM 시작: request body에 `creatorId`만 포함하고 기존처럼 동작.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | role/진입 정책 재확인 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | AppStep 라우팅 방식 확인 |
|
||||
| 3 | `P2-T1` | `P1-T2` | 아니요 | 다국어 생성 절차 확인 |
|
||||
| 4 | `P2-T2` | `P2-T1` | 아니요 | 기존 팔로우 API 계약 확인 |
|
||||
| 5 | `P2-T3` | `P2-T2` | 아니요 | 검색 response model 확인 |
|
||||
| 6 | `P2-T4` | `P2-T2` 또는 `P2-T3` | 아니요 | 팝업 문구 재확인 |
|
||||
| 7 | `P2-T5` | `P2-T4` | 아니요 | 서버 request contract 확인 |
|
||||
| 8 | `P3-GATE` | Phase 2 전체 | 아니요 | 실패 소유 Task에 회귀 수정 Task 추가 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P2-T1 → P2-T2 → P2-T3 → P2-T4 → P2-T5 → P3-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- `Pods/**`, `generated/**`, `build/**`를 직접 수정하지 않는다.
|
||||
- 새 dependency를 추가하지 않는다.
|
||||
- 기존 DM 방 UI와 WebSocket 전송 로직을 변경하지 않는다.
|
||||
- `recipientId`와 `creatorId`를 서로 다른 값으로 동시에 보내는 request를 만들지 않는다.
|
||||
- 실패하는 검증을 통과시키기 위해 테스트를 삭제·완화하거나 타입 오류를 우회하지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 구현 계획이 충돌하면 PRD Decision Log를 먼저 갱신한다.
|
||||
- 검색 API response model이 기존 `SearchResponseItem`으로 충분하지 않으면 코드 변경 전에 이 문서에 Task를 추가한다.
|
||||
- 서버가 `recipientId` request를 거부하면 `P2-T5`를 중단하고 request contract 확인 Task를 추가한다.
|
||||
- UI가 Figma와 충돌하면 Figma 구조를 우선하되 기존 V2 component token을 벗어나지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 작성 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: PRD와 Goal 실행형 구현 계획 초안 작성.
|
||||
- 왜: 사용자 요청이 “모호한 것은 인터뷰하고 문서만 생성, 코드 구현 금지”였기 때문.
|
||||
- 어떻게:
|
||||
- `docs/sample/sample-prd.md` 확인 — 샘플 구조 반영.
|
||||
- `docs/sample/sample-plan-task.md` 확인 — Phase/Task/Goal 구조 반영.
|
||||
- Figma `2372:23496`, `2372:23521` 확인 — 화면과 팝업 기준 반영.
|
||||
- 기존 코드 조사 — `MainChatView`, `FollowCreatorViewModel`, `UserApi.searchUser`, `UserCreatorChatRepository`, `SodaV2ActionModal` 기준 반영.
|
||||
- 남은 항목: 사용자 문서 검토.
|
||||
- 다음 행동: PRD와 plan-task 승인 또는 수정 요청.
|
||||
|
||||
### Phase 1~2 구현 — 2026-09-14
|
||||
|
||||
- 상태: 빌드 검증 완료, 수동 QA 필요
|
||||
- 무엇을:
|
||||
- 메인 대화 탭에 크리에이터 전용 신규 DM 시작 `+` 버튼을 추가했다.
|
||||
- `AppStep.userCreatorChatRecipientSearch` 라우팅과 리스너 선택 화면을 추가했다.
|
||||
- 팔로우 전체 리스트, 2글자 이상 500ms debounce 검색, empty 상태, 확인 팝업, `recipientId` 기반 방 생성을 연결했다.
|
||||
- 기존 `creatorId` 기반 `createRoom(creatorId:)` 흐름은 유지했다.
|
||||
- 왜: PRD의 크리에이터 선제 DM 시작 요구사항을 기존 API와 V2 컴포넌트 재사용으로 구현하기 위해서다.
|
||||
- 어떻게:
|
||||
- 신규 Swift 파일은 프로젝트 수동 등록 위험이 있어 만들지 않고, 기존 컴파일 대상인 `UserCreatorChatRoomView.swift`에 선택 화면 타입을 추가했다.
|
||||
- `UserCreatorCreateRoomRequest`는 `encodeIfPresent`로 `recipientId`와 `creatorId` 중 nil 필드를 request body에서 제외하도록 했다.
|
||||
- 검색 응답은 기존 `UserRepository.searchUser` 사용처와 동일하게 `ApiResponse<[GetRoomDetailUser]>`로 디코딩했다.
|
||||
- 검색어를 2글자 미만으로 지울 때 진행 중인 검색 요청을 취소하고 초기 팔로우 리스트로 복귀하도록 했다.
|
||||
- 검색/팔로우 목록/방 생성 로딩 상태를 분리해 지연된 검색 실패가 방 생성 중복 방지 상태를 해제하지 않도록 했다.
|
||||
- 최신 검색 요청만 성공·실패 상태를 반영하고, 새 검색 시작 시 이전 검색 결과를 비워 현재 검색어와 목록이 어긋나지 않도록 했다.
|
||||
- 검증:
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` — 성공.
|
||||
- 첫 빌드는 Swift 접근제어 오류를 발견했고 `UserCreatorChatRecipientItem` 접근 수준을 보정했다.
|
||||
- 두 번째 빌드는 코드사인/리소스 복사 단계에서 120초 제한으로 중단됐다.
|
||||
- 세 번째 빌드는 600초 제한으로 재실행해 `** BUILD SUCCEEDED **` 확인.
|
||||
- 검색 취소 로직 보정 후 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 재실행해 `** BUILD SUCCEEDED **` 확인.
|
||||
- Oracle 코드 리뷰에서 검색 취소·지연 실패·이전 결과 잔존 이슈를 지적받았고, 상태 처리 보정 후 동일 빌드를 재실행해 `** BUILD SUCCEEDED **` 확인.
|
||||
- 수동 QA 미수행:
|
||||
- 이 환경에서 로그인된 크리에이터/비크리에이터 계정과 실기기 또는 시뮬레이터 네트워크 관찰을 아직 실행하지 못했다.
|
||||
- 아래 Phase 3 Gate의 수동 검증 항목은 앱 실행 환경에서 별도 확인해야 한다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | 코드 구현 없이 문서만 작성한다. | 사용자 직접 지시 | 전체 |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 초기 목록은 기존 팔로우 전체 리스트 API를 사용한다. | 사용자 답변 `B` | `P2-T2` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 새 크리에이터→리스너 DM 시작 request는 `recipientId`를 사용한다. | 서버 request contract | `P2-T5` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `ISSUE-001` | Medium | 처리 완료 | 현재 iOS `UserCreatorCreateRoomRequest`는 `creatorId` 단일 필드만 가진다. | `P2-T5` | request model을 optional `recipientId`/`creatorId` 구조로 확장했다. |
|
||||
| `ISSUE-002` | Low | 처리 완료 | 신규 Swift 파일은 `project.pbxproj` 수동 등록이 필요해 누락 시 빌드 실패 위험이 있다. | `P1-T2`, `P2-T1`~`P2-T5` | 기존 컴파일 대상인 `UserCreatorChatRoomView.swift`에 선택 화면 타입을 추가했다. |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
구현 완료 보고에는 아래 항목을 기록한다.
|
||||
|
||||
- 구현 결과: 완료한 Phase와 사용자가 수행할 수 있게 된 DM 시작 흐름.
|
||||
- 변경: 주요 파일과 사용자에게 보이는 동작.
|
||||
- 결정: 적용된 Decision Log ID와 결정 내용.
|
||||
- 검증: 실행한 명령, exit code, 수동 QA 성공·실패 결과.
|
||||
- 남은 항목: 외부 의존, 후속 범위, 또는 없음.
|
||||
- 문서: 갱신한 PRD, plan-task, review 문서 경로.
|
||||
@@ -0,0 +1,282 @@
|
||||
# 크리에이터 DM 리스너 선택 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 검토 중 |
|
||||
| 작성일 | `2026-09-14` |
|
||||
| 최종 수정일 | `2026-09-14` |
|
||||
| 대상 제품 | 크리에이터가 리스너에게 먼저 DM 보내기 |
|
||||
| 작성자·결정권자 | 제품 결정: 요청자 / 문서 작성: Sisyphus |
|
||||
| 관련 API Contract | 이 문서 §11 |
|
||||
| 관련 구현 계획 | `docs/20260914_크리에이터_DM_리스너_선택/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 | 구현 처리 |
|
||||
|---|---|---|
|
||||
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||
| 미결 | 제품·UX·운영 결정이 더 필요함 | 구현 전 질문 또는 API 확인 필요 |
|
||||
| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | Decision Log에 기록 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
현재 리스너는 크리에이터 채널에서 크리에이터에게 바로 DM을 시작할 수 있지만, 크리에이터가 리스너에게 먼저 DM을 시작하는 진입점은 없다. 이 기능은 메인 대화 탭에서 크리에이터가 팔로우 리스트 또는 닉네임 검색으로 리스너를 선택하고, 확인 팝업 후 기존 유저-크리에이터 DM 방 생성 API로 DM 방에 진입하게 한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
현재 사용자는 다음 문제를 겪는다.
|
||||
|
||||
- 크리에이터가 리스너에게 선제적으로 1:1 DM을 시작할 수 없다.
|
||||
- 기존 DM 생성 흐름은 크리에이터 채널에서 리스너가 크리에이터에게 보내는 방향을 기준으로 구성되어 있다.
|
||||
- 크리에이터가 리스너를 찾기 위해 별도 화면이나 외부 수단을 사용해야 한다.
|
||||
|
||||
문제를 해결했다는 판단은 크리에이터 계정이 메인 대화 탭에서 리스너를 선택해 기존 DM 방 생성 API를 호출하고, 생성된 DM 방으로 진입하는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 크리에이터가 메인 대화 탭에서 리스너에게 먼저 DM을 시작할 수 있다.
|
||||
- 초기 화면은 기존 팔로우 전체 리스트 페이지와 동일한 API를 사용한다.
|
||||
- 검색은 2글자 이상일 때만 `GET /member/search`를 500ms debounce 후 호출한다.
|
||||
- 선택한 유저에게 메시지를 보낼지 확인하는 다이얼로그를 보여준다.
|
||||
- 보내기 시 기존 유저-크리에이터 DM 방 생성 API를 재사용한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 메인 대화 탭 우측 하단에 홈의 플로팅 `+` 버튼과 같은 진입점을 제공한다.
|
||||
- 새 화면은 Figma `2372:23496` 기준의 검색 바, 팔로우 섹션, 유저 리스트 구조를 따른다.
|
||||
- 확인 팝업은 Figma `2372:23521` 기준 문구와 V2 공통 `SodaV2ActionModal`을 재사용한다.
|
||||
- 검색 결과가 없을 때 화면 중앙에 `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`를 다국어 문구로 표시한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 새 채팅 프로토콜, WebSocket 전송 방식, 메시지 본문 전송 기능은 만들지 않는다.
|
||||
- 기존 채팅방 UI(`UserCreatorChatRoomView`)의 메시지 표시·전송 UX를 변경하지 않는다.
|
||||
- 팔로우 리스트 API의 서버 계약을 새로 정의하지 않는다. 기존 API를 재사용한다.
|
||||
- 검색 API의 서버 응답 필드, 필터링 정책, 차단 유저 제외 정책을 클라이언트에서 추정하지 않는다.
|
||||
- 새 공통 모달 컴포넌트를 만들지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| 크리에이터 | 리스너에게 먼저 DM 시작 | 대화 탭 진입, 팔로우 리스트 조회, 검색, 유저 선택, 확인 후 DM 방 진입 | iOS 앱 |
|
||||
| 리스너 | 크리에이터가 시작한 DM 수신 | 기존 DM 방에서 메시지 확인 | iOS 앱 |
|
||||
|
||||
### 5.2 권한
|
||||
|
||||
- 인증 주체: 로그인된 사용자.
|
||||
- 허용 역할: `MemberRole.CREATOR`인 크리에이터.
|
||||
- 거부 조건: 비로그인 또는 크리에이터가 아닌 사용자는 메인 대화 탭의 신규 `+` 진입점을 보지 않는다.
|
||||
- 리소스 소유권: 방 생성 권한과 recipient 검증은 서버의 `/api/v2/user-creator-chat/rooms/create` 정책을 따른다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 크리에이터가 메인 대화 탭에 진입한다.
|
||||
2. 우측 하단 `+` 버튼을 터치한다.
|
||||
3. 새 유저 선택 화면이 열린다.
|
||||
4. 초기 상태에서 기존 팔로우 전체 리스트 API의 결과를 보여준다.
|
||||
5. 검색창에 2글자 이상 입력하면 500ms 동안 추가 입력이 멈춘 뒤 `GET /member/search?nickname={query}`를 호출한다.
|
||||
6. 검색 결과가 없으면 중앙에 empty 문구를 표시한다.
|
||||
7. 유저를 터치하면 `메시지 보내기` 확인 팝업을 표시한다.
|
||||
8. 보내기를 터치하면 기존 DM 방 생성 API를 호출한다.
|
||||
9. 성공 시 반환된 `roomId`로 `AppStep.userCreatorChatRoom(roomId:)`에 진입한다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
MainView / MainTab.chat
|
||||
MainChatView
|
||||
+ floating action button
|
||||
-> AppStep.userCreatorChatRecipientSearch
|
||||
UserCreatorChatRecipientSearchView
|
||||
-> AppStep.userCreatorChatRoom(roomId: Int)
|
||||
```
|
||||
|
||||
- 새 라우트 이름은 구현 시 코드 스타일에 맞춰 조정할 수 있으나, 역할은 “크리에이터가 DM을 시작할 리스너 선택 화면” 하나로 제한한다.
|
||||
- 기존 `AppStep.userCreatorChatCreator(creatorId:)`는 리스너가 크리에이터 채널에서 DM을 시작하는 흐름으로 유지한다.
|
||||
- 새 흐름은 유저 선택 후 `recipientId` 기반으로 방을 생성한다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
### 8.1 진입점
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CDM-ENTRY-001` | 확정 | 메인 대화 탭 우측 하단에 홈처럼 `+` 플로팅 버튼을 추가한다. | 크리에이터 계정에서만 보이고, 탭 시 새 리스너 선택 화면으로 이동한다. | `P1-T1` |
|
||||
| `CDM-ENTRY-002` | 확정 | 비크리에이터와 비로그인 사용자는 신규 `+` 버튼을 보지 않는다. | `MemberRole.CREATOR`가 아니면 버튼이 렌더링되지 않는다. | `P1-T1` |
|
||||
|
||||
### 8.2 리스너 선택 화면
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CDM-LIST-001` | 확정 | 새 화면은 Figma `2372:23496` 구조를 따른다. | 뒤로가기, 검색 바, 섹션 타이틀, 리스트가 검은 배경 위에 배치된다. | `P2-T1` |
|
||||
| `CDM-LIST-002` | 확정 | 초기 진입 시 기존 팔로우 전체 리스트 페이지와 동일한 API를 호출한다. | `FollowCreatorRepository.getFollowedCreatorAllList(page:size:)`와 같은 endpoint·pagination 정책을 사용한다. | `P2-T2` |
|
||||
| `CDM-LIST-003` | 확정 | 초기 리스트 아이템 터치 시 확인 팝업을 보여준다. | 터치만으로 방 생성 API가 바로 호출되지 않는다. | `P2-T4` |
|
||||
|
||||
### 8.3 검색
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CDM-SEARCH-001` | 확정 | 검색어가 2글자 미만이면 검색 API를 호출하지 않는다. | 0~1글자 입력 중 network 요청 0회, 화면은 초기 팔로우 리스트 기준을 유지한다. | `P2-T3` |
|
||||
| `CDM-SEARCH-002` | 확정 | 검색어가 2글자 이상이면 500ms debounce 후 `GET /member/search`를 호출한다. | 연속 입력 중에는 마지막 입력 후 500ms가 지나기 전 요청하지 않는다. | `P2-T3` |
|
||||
| `CDM-SEARCH-003` | 확정 | 검색 결과가 없을 때 중앙 empty 문구를 표시한다. | `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`가 다국어 키로 표시된다. | `P2-T3` |
|
||||
|
||||
### 8.4 확인 팝업과 방 생성
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CDM-MODAL-001` | 확정 | 유저 터치 시 V2 내부 공통 팝업을 재사용한다. | `SodaV2ActionModal`을 사용하고 새 모달 컴포넌트를 만들지 않는다. | `P2-T4` |
|
||||
| `CDM-MODAL-002` | 확정 | 팝업 문구는 `메시지 보내기`, `{{사용자이름}}에게 메시지를 보낼까요?`, `취소`, `보내기`를 다국어 처리한다. | 모든 문구가 `I18n` 키를 통해 참조된다. | `P2-T4` |
|
||||
| `CDM-CREATE-001` | 확정 | 보내기 시 기존 유저-크리에이터 DM 방 생성 API를 호출한다. | `POST /api/v2/user-creator-chat/rooms/create` 성공 시 `roomId`를 받아 DM 방으로 진입한다. | `P2-T5` |
|
||||
| `CDM-CREATE-002` | 확정 | 크리에이터가 리스너에게 보내는 흐름은 선택한 유저 ID를 `recipientId`로 보낸다. | request body가 `recipientId`와 `creatorId`를 동시에 서로 다른 값으로 보내지 않는다. | `P2-T5` |
|
||||
|
||||
## 9. UI/UX Expectations
|
||||
|
||||
### 9.1 디자인 기준
|
||||
|
||||
- 신규 화면 Figma: `2372:23496` (`chat_src_001`, 402×874)
|
||||
- 확인 팝업 Figma: `2372:23521` (`Frame 1707482932`, 340×207)
|
||||
- 검색 안내 문구: Figma 기준 `팬 이름을 입력하세요`
|
||||
- 초기 섹션 타이틀: Figma 기준 `팔로워`
|
||||
- 리스트 아이템: 원형 프로필 이미지와 닉네임 텍스트 구조를 사용한다.
|
||||
|
||||
### 9.2 화면 상태
|
||||
|
||||
- loading: 초기 팔로우 리스트와 검색 요청 중 기존 프로젝트 loading 패턴을 따른다.
|
||||
- empty: 검색 결과 0건일 때 중앙 안내 문구 표시.
|
||||
- error: 기존 toast 또는 공통 오류 메시지 정책을 따른다.
|
||||
- success: 방 생성 성공 시 별도 성공 toast 없이 채팅방으로 이동한다.
|
||||
|
||||
## 10. 다국어 문구
|
||||
|
||||
| 용도 | 한국어 문구 | 비고 |
|
||||
|---|---|---|
|
||||
| 검색 안내 문구 | 팬 이름을 입력하세요 | Figma 기준 |
|
||||
| 초기 섹션 타이틀 | 팔로워 | Figma 기준 |
|
||||
| 검색 empty 1행 | 사용자가 없어요. | 줄바꿈 포함 |
|
||||
| 검색 empty 2행 | 다른 이름으로 다시 검색해 주세요. | 줄바꿈 포함 |
|
||||
| 팝업 제목 | 메시지 보내기 | Figma 기준 |
|
||||
| 팝업 본문 | {{사용자이름}}에게 메시지를 보낼까요? | 닉네임 치환 |
|
||||
| 팝업 취소 | 취소 | 기존 `I18n.Common.cancel` 재사용 가능 |
|
||||
| 팝업 보내기 | 보내기 | 새 키 또는 기존 동일 의미 키 재사용 |
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 초기 팔로우 전체 리스트
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 기존 호출 지점 | `FollowCreatorViewModel.getFollowedCreatorAllList()` |
|
||||
| Repository | `FollowCreatorRepository.getFollowedCreatorAllList(page:size:)` |
|
||||
| Method | `GET` |
|
||||
| Path | `/live/recommend/following/channel/all/list` |
|
||||
| Query | `page`, `size`, `timezone` |
|
||||
| Response decode | `ApiResponse<GetCreatorFollowingAllListResponse>` |
|
||||
| Item model | `GetCreatorFollowingAllListItem(creatorId, nickname, profileImageUrl, isFollow, isNotify)` |
|
||||
|
||||
### 11.2 유저 검색
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 기존 API | `UserApi.searchUser(nickname:)` |
|
||||
| Repository | `UserRepository.searchUser(nickname:)` |
|
||||
| Method | `GET` |
|
||||
| Path | `/member/search` |
|
||||
| Query | `nickname` |
|
||||
| 호출 조건 | 검색어 trim 후 2글자 이상, 500ms debounce 완료 |
|
||||
|
||||
### 11.3 DM 방 생성
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 기존 API | `UserCreatorChatApi.createRoom` |
|
||||
| Repository | `UserCreatorChatRepository.createRoom(...)` |
|
||||
| Method | `POST` |
|
||||
| Path | `/api/v2/user-creator-chat/rooms/create` |
|
||||
| 기존 iOS request | `UserCreatorCreateRoomRequest(creatorId: Int)` |
|
||||
| 신규 요구 request | `recipientId: Int?`, `creatorId: Int?` 중 하나만 유효하게 전송 |
|
||||
| 성공 response | `UserCreatorCreateRoomResponse(roomId: Int)` |
|
||||
|
||||
서버 request 기준:
|
||||
|
||||
```kotlin
|
||||
data class CreateUserCreatorChatRoomRequest(
|
||||
val recipientId: Long? = null,
|
||||
val creatorId: Long? = null
|
||||
)
|
||||
```
|
||||
|
||||
- 리스너가 크리에이터 채널에서 DM 시작: 기존처럼 `creatorId` 전송.
|
||||
- 크리에이터가 리스너에게 DM 시작: 선택한 유저의 ID를 `recipientId`로 전송.
|
||||
- `recipientId`와 `creatorId`가 서로 다른 값으로 동시에 전송되는 상태는 만들지 않는다.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 인증 header는 기존 Moya target의 `Authorization: Bearer {token}` 정책을 따른다.
|
||||
- 검색어, 닉네임, member ID 외 민감정보를 로그에 남기지 않는다.
|
||||
- 방 생성 실패 시 서버 message 또는 `I18n.Common.commonError`를 사용한다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 검색 debounce 시간은 500ms로 고정한다.
|
||||
- 2글자 미만 검색어는 network 요청을 보내지 않는다.
|
||||
- 검색어가 바뀌면 이전 pending debounce 작업은 취소한다.
|
||||
- pagination은 기존 팔로우 전체 리스트의 `page = 1`, `size = 10`, `isLast` 정책을 따른다.
|
||||
- 신규 dependency를 추가하지 않는다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [ ] 크리에이터 계정의 메인 대화 탭 우측 하단에 `+` 버튼이 표시된다.
|
||||
- [ ] `+` 버튼을 누르면 Figma 기준 리스너 선택 화면으로 이동한다.
|
||||
- [ ] 화면 최초 진입 시 기존 팔로우 전체 리스트 API가 호출된다.
|
||||
- [ ] 검색어 2글자 미만에서는 `/member/search` 요청이 없다.
|
||||
- [ ] 검색어 2글자 이상에서 마지막 입력 500ms 후 `/member/search?nickname=` 요청이 1회 발생한다.
|
||||
- [ ] 검색 결과가 없으면 다국어 empty 문구가 중앙에 표시된다.
|
||||
- [ ] 유저 터치 시 `SodaV2ActionModal` 확인 팝업이 표시된다.
|
||||
- [ ] 보내기 터치 시 `recipientId` 기반으로 기존 DM 방 생성 API가 호출되고, 성공 시 DM 방으로 이동한다.
|
||||
|
||||
### 14.2 추적성 완료 기준
|
||||
|
||||
- [ ] 모든 `확정` 요구사항이 `plan-task.md`의 Goal에 연결된다.
|
||||
- [ ] 모든 신규 문구가 다국어 키에 연결된다.
|
||||
- [ ] 기존 크리에이터 채널 DM 시작 흐름이 회귀하지 않는다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 없음 | 확정 | 인터뷰로 초기 리스트 API 기준 확인 완료 | 기존 팔로우 전체 리스트 API 사용 | 요청자 | 2026-09-14 | `P2-T2` |
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `CDM-ENTRY-001~002` | 해당 없음 | 1 | `P1-T1` | 빌드 | 크리에이터/비크리에이터 버튼 노출 확인 |
|
||||
| `CDM-LIST-001~003` | §11.1 | 2 | `P2-T1`, `P2-T2`, `P2-T4` | 빌드 | 초기 목록과 팝업 확인 |
|
||||
| `CDM-SEARCH-001~003` | §11.2 | 2 | `P2-T3` | 빌드 | 2글자/500ms/empty 확인 |
|
||||
| `CDM-MODAL-001~002`, `CDM-CREATE-001~002` | §11.3 | 2 | `P2-T4`, `P2-T5` | 빌드 | request payload와 방 진입 확인 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | 코드 구현 없이 PRD와 `plan-task.md`만 작성한다. | 사용자 직접 지시 | 전체 |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 새 화면 초기 목록은 기존 팔로우 전체 리스트 페이지와 동일한 API를 사용한다. | 사용자 답변 `B` | `CDM-LIST-002`, §11.1, `P2-T2` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 크리에이터가 리스너에게 보내는 새 흐름은 `recipientId`를 사용하고, 기존 크리에이터 채널 흐름은 `creatorId`를 유지한다. | 서버 request contract | `CDM-CREATE-002`, §11.3, `P2-T5` |
|
||||
|
||||
## 18. 변경 관리
|
||||
|
||||
요구사항 변경 시 다음을 확인한다.
|
||||
|
||||
- [ ] Decision Log에 변경 이유와 날짜를 기록했다.
|
||||
- [ ] 관련 요구사항 상태·본문·수용 기준을 갱신했다.
|
||||
- [ ] API request/response/error 계약을 갱신했다.
|
||||
- [ ] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신했다.
|
||||
- [ ] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않았다.
|
||||
@@ -0,0 +1,335 @@
|
||||
# 크리에이터 채널 공유 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료, 수동 검증 대기 |
|
||||
| 작성일 | `2026-09-14` |
|
||||
| 요구사항 기준 | `docs/20260914_크리에이터_채널_공유/prd.md` |
|
||||
| API 기준 | 해당 없음 |
|
||||
| 현재 Phase | Phase 1: 채널 공유 진입점과 공유 시트 연결 |
|
||||
| 현재 활성 Goal | P1-GATE 수동 검증 |
|
||||
|
||||
## 목표
|
||||
|
||||
내 채널과 다른 사람 채널에서 모두 기존 채널 공유 메시지와 OneLink URL을 iOS 공유 시트로 공유할 수 있게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 수동 검증 대기 | `4/4` | `P1-GATE` | 실제 채널 진입 후 공유 시트 표시 수동 확인 필요 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||
- 구현은 완료했으며, 사용자 요청에 따라 visual QA 없이 수동 테스트로 최종 확인한다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 내 채널 title bar 우측 상단 공유 버튼 추가 계획
|
||||
- 다른 사람 채널 더보기 overlay 최상단 `채널 공유` 항목 추가 계획
|
||||
- 기존 `UserProfileViewModel.shareChannel`과 동일한 OneLink/메시지/`ActivityViewController` 공유 동작 연결 계획
|
||||
- 공유 실패 popup, 빌드, 수동 검증 기준
|
||||
|
||||
### 제외
|
||||
|
||||
- 새 공유 문구 또는 새 딥링크 계약 생성
|
||||
- 새 API 추가
|
||||
- 공유 URL 생성 로직의 공용 helper 분리
|
||||
- 커밋 생성
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Swift, SwiftUI, 기존 `ActivityViewController`, 기존 OneLink URL 생성 함수
|
||||
- 아키텍처: `CreatorChannelView`는 화면 상태와 공유 시트 표시 상태를 소유하고, title bar와 더보기 overlay는 action closure로 공유 동작을 호출한다.
|
||||
- 데이터·보안: 공유 메시지에는 nickname과 OneLink URL만 포함하고 token·개인정보를 포함하지 않는다.
|
||||
- 의존성: 신규 dependency를 추가하지 않는다.
|
||||
- 계약: `af_dp=voiceon://`, `deep_link_value=channel`, `deep_link_sub5={creatorId}`를 기존 공유와 동일하게 사용한다.
|
||||
- 검증: 테스트 타깃이 확인되지 않으므로 focused build와 수동 검증을 완료 증거로 사용한다.
|
||||
|
||||
## 주요 대상 파일
|
||||
|
||||
### 수정
|
||||
|
||||
- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift`
|
||||
- `SodaLive/Sources/Report/ProfileReportMenuView.swift`
|
||||
- `SodaLive/Sources/I18n/I18n.swift`
|
||||
|
||||
### 확인
|
||||
|
||||
- `SodaLive/Sources/Explorer/Profile/UserProfileView.swift`
|
||||
- `SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift`
|
||||
- `SodaLive/Sources/Common/ActivityViewController.swift`
|
||||
- `docs/agent-guides/build-test-verification.md`
|
||||
|
||||
## Phase 1: 채널 공유 진입점과 공유 시트 연결
|
||||
|
||||
**Phase 결과:** 내 채널 공유 버튼과 다른 사람 채널 더보기 공유 항목에서 동일한 채널 공유 시트를 열 수 있다.
|
||||
|
||||
**선행조건:** `docs/20260914_크리에이터_채널_공유/prd.md`의 요구사항 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T4`와 `P1-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 title bar 공유 진입점 정의
|
||||
|
||||
**Goal 실행 `P1-T1`:** 내 채널 title bar에 직접 공유 버튼을 표시할 수 있도록 `CreatorChannelTitleBar` contract를 확장한다.
|
||||
|
||||
- **시작 조건:** `CCSHARE-001` 확정.
|
||||
- **완료 증거:** title bar가 내 채널과 다른 사람 채널 action 구성을 분리하고 `ic_audio_content_share` 버튼을 표시할 수 있음.
|
||||
- **범위 밖:** 공유 URL 생성과 `ActivityViewController` 표시.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift`
|
||||
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- Test: 테스트 타깃 없음. 빌드와 수동 검증으로 대체.
|
||||
|
||||
**TDD 예외 사유:** 현재 `SodaLive.xcodeproj/project.pbxproj` 기준으로 테스트 번들 타깃이 확인되지 않아 UI contract 변경은 focused build와 수동 검증으로 판정한다.
|
||||
|
||||
**대체 검증 방법:** 구현 후 아래 명령과 수동 확인을 수행한다.
|
||||
|
||||
```bash
|
||||
rg "ic_audio_content_share|onTapShare|showsCreatorActions|CreatorChannelTitleBar" SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelTitleBar.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `CreatorChannelTitleBar`에서 공유 action과 `ic_audio_content_share` 사용이 확인된다.
|
||||
- `CreatorChannelView`가 내 채널 여부에 따라 공유 버튼 또는 더보기 버튼 구성을 전달한다.
|
||||
- 빌드 exit code가 0이다.
|
||||
|
||||
- [x] title bar contract에 공유 action을 추가한다.
|
||||
- [x] 내 채널일 때 공유 버튼이 보이는 조건을 연결한다.
|
||||
- [x] 다른 사람 채널일 때 기존 더보기 버튼이 유지되는지 확인한다.
|
||||
- [x] `ic_audio_content_share` asset 이름을 그대로 사용한다.
|
||||
|
||||
#### Task 1.2 공유 메시지와 공유 시트 상태 연결
|
||||
|
||||
**Goal 실행 `P1-T2`:** 기존 `UserProfileViewModel.shareChannel`과 동일한 공유 메시지·OneLink 생성·`ActivityViewController` 표시 흐름을 `CreatorChannelView`에 연결한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, `CCSHARE-002`, `CCSHARE-006`, `CCSHARE-007` 확정.
|
||||
- **완료 증거:** 공유 action이 `creatorId`, `nickname`으로 기존 OneLink 파라미터를 만들고 공유 시트를 표시한다.
|
||||
- **범위 밖:** 공유 helper 분리와 새 공유 문구 추가.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
|
||||
- Check: `SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift`
|
||||
- Check: `SodaLive/Sources/Common/ActivityViewController.swift`
|
||||
- Test: 테스트 타깃 없음. 빌드와 수동 검증으로 대체.
|
||||
|
||||
**TDD 예외 사유:** 공유 시트는 iOS system UI이고 현재 테스트 타깃이 없어 자동 UI test를 작성하지 않는다.
|
||||
|
||||
**대체 검증 방법:** 구현 후 아래 명령과 수동 확인을 수행한다.
|
||||
|
||||
```bash
|
||||
rg "af_dp|deep_link_value|deep_link_sub5|shareChannel|shareMessage|isShowShareView|ActivityViewController" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- V2 크리에이터 채널 공유 로직이 기존 `UserProfileViewModel.shareChannel`과 같은 파라미터를 사용한다.
|
||||
- `ActivityViewController`가 `shareMessage`를 받아 표시된다.
|
||||
- OneLink 생성 실패 시 `I18n.MemberChannel.shareLinkCreateFailed` popup 흐름이 있다.
|
||||
- 빌드 exit code가 0이다.
|
||||
|
||||
- [x] `CreatorChannelViewModel` 또는 `CreatorChannelView`에 공유 메시지 상태를 추가한다.
|
||||
- [x] `af_dp`, `deep_link_value`, `deep_link_sub5` 값을 기존 채널 공유와 동일하게 구성한다.
|
||||
- [x] 성공 시 `I18n.MemberChannel.shareChannelMessage(nickname)`과 URL을 줄바꿈으로 합친다.
|
||||
- [x] 실패 시 기존 실패 문구 popup을 표시한다.
|
||||
- [x] `CreatorChannelView`에 `ActivityViewController` presentation을 연결한다.
|
||||
|
||||
#### Task 1.3 더보기 메뉴 최상단 공유 항목 추가
|
||||
|
||||
**Goal 실행 `P1-T3`:** 다른 사람 채널 더보기 overlay 최상단에 `채널 공유` 항목을 추가하고, 탭 시 공유 action을 실행한다.
|
||||
|
||||
- **시작 조건:** `P1-T2` 완료, `CCSHARE-003`~`CCSHARE-005` 확정.
|
||||
- **완료 증거:** 다른 사람 채널 더보기 메뉴 순서가 `채널 공유`, 차단/차단 해제, 사용자 신고, 프로필 신고 순서다.
|
||||
- **범위 밖:** 기존 차단/신고 API 동작 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `SodaLive/Sources/Report/ProfileReportMenuView.swift`
|
||||
- Modify: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- Modify: `SodaLive/Sources/I18n/I18n.swift`
|
||||
- Test: 테스트 타깃 없음. 빌드와 수동 검증으로 대체.
|
||||
|
||||
**TDD 예외 사유:** 기존 overlay menu는 SwiftUI view이고 현재 테스트 타깃이 없어 자동 UI test를 작성하지 않는다.
|
||||
|
||||
**대체 검증 방법:** 구현 후 아래 명령과 수동 확인을 수행한다.
|
||||
|
||||
```bash
|
||||
rg "channelShare|shareChannel|ProfileReportMenuView|userBlockAction|userReportAction|profileReportAction" SodaLive/Sources/Report/ProfileReportMenuView.swift SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/I18n/I18n.swift
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `ProfileReportMenuView`에 공유 항목과 공유 action이 있고 기존 차단/신고 action이 유지된다.
|
||||
- `채널 공유` I18n 문구가 ko/en/ja에 제공된다.
|
||||
- 빌드 exit code가 0이다.
|
||||
|
||||
- [x] `ProfileReportMenuView`에 공유 action closure를 추가한다.
|
||||
- [x] 공유 row를 기존 차단/신고 row보다 위에 배치한다.
|
||||
- [x] 공유 row 탭 시 overlay를 닫고 공유 action을 호출한다.
|
||||
- [x] 기존 차단/차단 해제, 사용자 신고, 프로필 신고 row와 action을 변경하지 않는다.
|
||||
- [x] `I18n`에 공유 메뉴 문구를 추가한다.
|
||||
|
||||
#### Task 1.4 회귀 정리와 문서 기록
|
||||
|
||||
**Goal 실행 `P1-T4`:** 구현 결과가 PRD 범위를 벗어나지 않는지 확인하고 검증 기록을 누적한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`~`P1-T3` 완료.
|
||||
- **완료 증거:** PRD 요구사항 추적표와 실제 코드 변경 범위가 일치하고, 검증 명령 결과가 Progress에 기록됨.
|
||||
- **범위 밖:** 발견된 범위 밖 문제의 임의 수정.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260914_크리에이터_채널_공유/plan-task.md`
|
||||
- Check: `docs/20260914_크리에이터_채널_공유/prd.md`
|
||||
- Test: 문서 대조와 빌드 결과 기록.
|
||||
|
||||
**대체 검증 방법:** 아래 명령과 수동 확인 결과를 Progress에 기록한다.
|
||||
|
||||
```bash
|
||||
rg "CCSHARE-001|CCSHARE-002|CCSHARE-003|CCSHARE-004|CCSHARE-005|CCSHARE-006|CCSHARE-007" docs/20260914_크리에이터_채널_공유/prd.md docs/20260914_크리에이터_채널_공유/plan-task.md
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 모든 요구사항 ID가 PRD와 계획에 남아 있다.
|
||||
- 빌드 exit code가 0이다.
|
||||
- 수동 검증 항목의 실제 결과가 Progress에 기록되어 있다.
|
||||
|
||||
- [x] PRD 요구사항과 구현 Task 연결을 대조한다.
|
||||
- [x] 검증 명령의 실제 결과를 Progress에 누적한다.
|
||||
- [x] 범위 밖 발견 사항이 있으면 `발견된 문제` 표에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`, `P1-T2`, `P1-T3`, `P1-T4`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] 기존 차단/신고 메뉴와 팔로우/알림 title bar 동작이 회귀하지 않았다.
|
||||
- [x] 문서와 구현의 차이가 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 채널 공유 사용자 흐름과 공통 품질 기준을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** Phase 1의 모든 활성 Task goal 완료.
|
||||
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·완화와 관련 없는 기능 수정.
|
||||
|
||||
```bash
|
||||
rg "ic_audio_content_share|ActivityViewController|deep_link_value|deep_link_sub5|ProfileReportMenuView" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/Report/ProfileReportMenuView.swift
|
||||
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
|
||||
```
|
||||
|
||||
**Expected:** `rg` 결과에서 공유 버튼, 공유 시트, OneLink 파라미터, 더보기 메뉴 연결이 확인되고 빌드 exit code가 0이다.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [ ] 내 채널 진입 후 우측 상단 공유 버튼을 탭하면 iOS 공유 시트가 표시된다.
|
||||
- [ ] 다른 사람 채널 진입 후 더보기 메뉴 최상단 `채널 공유`를 탭하면 iOS 공유 시트가 표시된다.
|
||||
- [ ] 공유 메시지는 기존 채널 공유 메시지와 OneLink URL을 포함한다.
|
||||
- [ ] 다른 사람 채널 더보기 메뉴의 기존 차단/신고 항목이 그대로 동작한다.
|
||||
- [ ] OneLink 생성 실패를 유도할 수 있는 debug 조건이 있으면 실패 popup 문구를 확인하고, 없으면 코드 대조로 실패 경로를 확인한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | title bar contract 재검토 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 `UserProfileViewModel.shareChannel` 대조 |
|
||||
| 3 | `P1-T3` | `P1-T2` | 아니요 | `ProfileReportMenuView` contract 재검토 |
|
||||
| 4 | `P1-T4` | `P1-T1`~`P1-T3` | 아니요 | PRD/계획 불일치 보정 |
|
||||
| 5 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-T3 → P1-T4 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 새 API, 새 dependency, 새 딥링크 계약을 추가하지 않는다.
|
||||
- 공유 문구를 기존 채널 공유와 다르게 새로 만들지 않는다.
|
||||
- 기존 차단/신고 action을 삭제하거나 의미를 바꾸지 않는다.
|
||||
- `Pods/**`, `generated/**`, `build/**`를 수정하지 않는다.
|
||||
- test를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과시키지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 구현 중 발견한 기존 코드가 충돌하면 PRD Decision Log에 정정 행을 먼저 추가한다.
|
||||
- 공유 URL 파라미터 변경이 필요하면 구현을 멈추고 사용자 결정을 받는다.
|
||||
- title bar layout에서 버튼이 겹치면 새 디자인을 추정하지 말고 최소 대안과 영향을 보고한다.
|
||||
- 범위 밖 회귀를 발견하면 임의 수정하지 않고 `발견된 문제`에 기록한다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 작성 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 신규 PRD와 plan-task 문서를 작성했다.
|
||||
- 왜: 사용자 요청에 따라 구현 전 요구사항과 실행 계획을 확정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `codegraph_explore` — `UserProfileViewModel.shareChannel`, `ActivityViewController`, `CreatorChannelTitleBar`, `ProfileReportMenuView`, `CreatorChannelViewModel` 현재 구현 확인.
|
||||
- `read` — `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md`, `docs/agent-guides/documentation-policy.md`, `docs/agent-guides/build-test-verification.md` 확인.
|
||||
- 남은 항목: 코드 구현과 구현 검증은 사용자 후속 승인 전까지 진행하지 않는다.
|
||||
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`부터 실행한다.
|
||||
|
||||
### 구현 및 검증 1차 실행 — 2026-09-15
|
||||
|
||||
- 상태: 구현 완료, 수동 검증 대기
|
||||
- 무엇을: 내 채널 title bar 공유 버튼, 다른 사람 채널 더보기 최상단 `채널 공유`, 기존 OneLink 기반 `ActivityViewController` 공유 시트 연결을 구현했다.
|
||||
- 왜: `CCSHARE-001`~`CCSHARE-007` 확정 요구사항을 현재 브랜치에서 충족하기 위해서다.
|
||||
- 어떻게:
|
||||
- `codegraph_explore` — `CreatorChannelViewModel.shareChannel`, `CreatorChannelTitleBar`, `ProfileReportMenuView`, `CreatorChannelView`, 기존 `UserProfileViewModel.shareChannel` 대조.
|
||||
- `rg "ic_audio_content_share|ActivityViewController|deep_link_value|deep_link_sub5|ProfileReportMenuView|channelShare|shareLinkCreateFailed" "SodaLive/Sources/V2/CreatorChannel" "SodaLive/Sources/Report/ProfileReportMenuView.swift" "SodaLive/Sources/I18n/I18n.swift" "SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift"` — 공유 버튼, 공유 시트, OneLink 파라미터, 실패 popup, I18n 연결 확인.
|
||||
- `git diff --check` — 통과.
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` — `BUILD SUCCEEDED`.
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "platform=iOS Simulator,id=E9FC7721-AA96-440F-8349-2DC4B85F40F5" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-share-derived" CODE_SIGNING_ALLOWED=NO build` — simulator build 성공.
|
||||
- `xcrun simctl install` / `xcrun simctl launch` — `kr.co.vividnext.sodalive.debug2` 설치 및 실행 성공, launch pid `46360`.
|
||||
- 리뷰 검증 — reviewer/oracle `VERDICT: APPROVE`, blocker 없음.
|
||||
- 남은 항목: `simctl ui` tap option과 `cliclick`이 없어 실제 채널 진입 후 공유 시트 표시는 사용자 수동 테스트로 확인한다.
|
||||
- 다음 행동: 내 채널 공유 버튼, 다른 사람 채널 더보기 공유, 기존 차단/신고 항목을 실기기 또는 simulator에서 수동 확인한다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | 공유 기능 문서는 신규 `docs/20260914_크리에이터_채널_공유/` 아래에 작성한다. | 사용자 선택 B | 전체 |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 다른 사람 채널 더보기 메뉴의 `채널 공유` 항목은 최상단에 배치한다. | 사용자 선택 A | `P1-T3` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 공유 방식은 기존 `UserProfileView`와 동일한 `ActivityViewController`와 OneLink 파라미터를 재사용한다. | 사용자 요구사항과 코드 확인 | `P1-T2`, `P1-GATE` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `ISSUE-001` | Low | 수동 확인 필요 | 현재 환경에서 자동 탭 도구가 없어 실제 공유 시트 표시까지 자동 검증하지 못함 | `P1-GATE` | 사용자 수동 테스트로 내 채널/다른 사람 채널 공유 시트 표시 확인 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: Phase 1 크리에이터 채널 공유 흐름 완료
|
||||
|
||||
- 변경: 내 채널 title bar 공유 버튼, 다른 사람 채널 더보기 메뉴 최상단 공유 항목, 기존 OneLink 기반 공유 시트 연결
|
||||
- 결정: DEC-001 신규 문서 분리, DEC-002 공유 항목 최상단 배치, DEC-003 기존 공유 방식 재사용
|
||||
- 검증:
|
||||
- `rg "ic_audio_content_share|ActivityViewController|deep_link_value|deep_link_sub5|ProfileReportMenuView" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/Report/ProfileReportMenuView.swift` — 공유 연결 코드 확인 결과
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` — 빌드 성공 또는 실패와 핵심 오류
|
||||
- 수동 검증 — 내 채널 공유 버튼, 다른 사람 채널 더보기 공유, 기존 차단/신고 회귀 여부
|
||||
- 남은 항목: 구현 시 발견한 외부 의존 또는 후속 범위, 없으면 없음
|
||||
- 문서: `docs/20260914_크리에이터_채널_공유/prd.md`, `docs/20260914_크리에이터_채널_공유/plan-task.md`
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# PRD: 크리에이터 채널 공유
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료, 수동 검증 대기 |
|
||||
| 작성일 | `2026-09-14` |
|
||||
| 최종 수정일 | `2026-09-15` |
|
||||
| 대상 제품 | 크리에이터 채널 공유 |
|
||||
| 작성자·결정권자 | 제품 요구사항: 사용자, 문서 작성: Sisyphus |
|
||||
| 관련 API Contract | 해당 없음 |
|
||||
| 관련 구현 계획 | `docs/20260914_크리에이터_채널_공유/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 | 구현 처리 |
|
||||
|---|---|---|
|
||||
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||
| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | Decision Log에 제외 이유 기록 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
크리에이터 채널 사용자는 채널을 외부 앱으로 공유할 수 있어야 한다. 내 채널에서는 우측 상단에 공유 버튼을 바로 노출하고, 다른 사람 채널에서는 우측 상단 더보기 메뉴 안에서 채널 공유를 선택한다.
|
||||
|
||||
공유 동작은 기존 `UserProfileView`의 채널 공유와 동일하게 OneLink 메시지를 만든 뒤 `ActivityViewController`로 시스템 공유 시트를 표시한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 신규 `CreatorChannelView`에는 채널 공유 진입점이 없다.
|
||||
- 기존 레거시 `UserProfileView`에는 채널 공유 동작이 있으나, 신규 크리에이터 채널 title bar와 더보기 overlay에는 연결되어 있지 않다.
|
||||
- 사용자는 자신의 채널을 홍보하거나 다른 사람의 채널을 추천하려면 앱 밖으로 공유할 수 있어야 한다.
|
||||
|
||||
문제를 해결했다는 판단은 내 채널과 다른 사람 채널에서 모두 기존 채널 공유 메시지와 딥링크가 포함된 iOS 공유 시트가 열리는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 내 크리에이터 채널 우측 상단에 공유 버튼을 직접 표시한다.
|
||||
- 다른 사람 크리에이터 채널 더보기 메뉴 최상단에 `채널 공유` 항목을 표시한다.
|
||||
- 공유 결과는 기존 `UserProfileView` 채널 공유와 동일한 메시지·딥링크 형식을 사용한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 내 채널 사용자는 더보기 단계를 거치지 않고 바로 공유할 수 있다.
|
||||
- 다른 사람 채널 사용자는 기존 더보기 메뉴 안에서 공유, 차단, 신고 동작을 한 곳에서 선택할 수 있다.
|
||||
- 공유 버튼에는 `ic_audio_content_share` asset을 사용한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 새 공유 문구를 만들지 않는다.
|
||||
- 새 딥링크 계약이나 새 API를 만들지 않는다.
|
||||
- 공유 URL 생성 로직을 범용 helper로 리팩터링하지 않는다.
|
||||
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||
- 이 문서 작업에서는 코드 구현을 진행하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| 내 채널 소유자 | 내 채널 홍보 | title bar 공유 버튼 탭 | iOS 앱 |
|
||||
| 다른 사람 채널 방문자 | 채널 추천 | 더보기 메뉴에서 채널 공유 선택 | iOS 앱 |
|
||||
|
||||
권한 정책:
|
||||
|
||||
- 인증 여부와 무관하게 기존 `UserProfileView.shareChannel` 동작과 동일한 조건을 따른다.
|
||||
- 내 채널 여부는 기존 `CreatorChannelView`의 `isOwnCreatorChannel` 판단을 따른다.
|
||||
- 공유 대상 `creatorId`는 `CreatorChannelHomeResponse.creator.creatorId`를 사용한다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 사용자가 내 크리에이터 채널에 진입한다.
|
||||
2. 우측 상단 `ic_audio_content_share` 버튼을 탭한다.
|
||||
3. 앱이 기존 채널 공유 메시지와 OneLink URL을 생성한다.
|
||||
4. iOS `ActivityViewController` 공유 시트가 표시된다.
|
||||
|
||||
다른 사람 채널 흐름:
|
||||
|
||||
1. 사용자가 다른 사람 크리에이터 채널에 진입한다.
|
||||
2. 우측 상단 더보기 버튼을 탭한다.
|
||||
3. overlay 메뉴 최상단의 `채널 공유` 항목을 탭한다.
|
||||
4. 앱이 기존 채널 공유 메시지와 OneLink URL을 생성한다.
|
||||
5. iOS `ActivityViewController` 공유 시트가 표시된다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
CreatorChannelView(creatorId:)
|
||||
titleBar
|
||||
my channel: share button
|
||||
other channel: more button
|
||||
more overlay
|
||||
channel share
|
||||
block/report actions
|
||||
ActivityViewController
|
||||
```
|
||||
|
||||
별도 route나 신규 `AppStep`은 추가하지 않는다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
### 8.1 내 채널 공유 버튼
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CCSHARE-001` | 확정 | 내 채널 title bar 우측 상단에 공유 버튼을 직접 표시한다. | `isOwnCreatorChannel == true`일 때 `ic_audio_content_share` 버튼이 보이고 더보기 버튼은 공유 진입에 필요하지 않다. | `P1-T1` |
|
||||
| `CCSHARE-002` | 확정 | 공유 버튼 탭 시 기존 채널 공유 방식으로 iOS 공유 시트를 표시한다. | 공유 메시지에 `I18n.MemberChannel.shareChannelMessage(nickname)` 결과와 OneLink URL이 포함된다. | `P1-T2` |
|
||||
|
||||
### 8.2 다른 사람 채널 더보기 공유
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CCSHARE-003` | 확정 | 다른 사람 채널 title bar는 기존 더보기 버튼을 유지한다. | `isOwnCreatorChannel == false`일 때 더보기 버튼을 탭하면 overlay 메뉴가 열린다. | `P1-T3` |
|
||||
| `CCSHARE-004` | 확정 | 더보기 overlay 메뉴 최상단에 `채널 공유` 항목을 추가한다. | 메뉴 순서는 `채널 공유`가 기존 차단/신고 항목보다 위다. | `P1-T3` |
|
||||
| `CCSHARE-005` | 확정 | `채널 공유` 항목 탭 시 overlay를 닫고 공유 시트를 표시한다. | 공유 시트가 표시되며 기존 차단/신고 흐름은 변경되지 않는다. | `P1-T3` |
|
||||
|
||||
### 8.3 공유 링크와 메시지
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CCSHARE-006` | 확정 | 공유 링크 생성은 기존 `UserProfileViewModel.shareChannel`과 동일한 OneLink 파라미터를 사용한다. | `af_dp=voiceon://`, `deep_link_value=channel`, `deep_link_sub5={creatorId}`가 사용된다. | `P1-T2` |
|
||||
| `CCSHARE-007` | 확정 | 공유 실패 시 기존 채널 공유 실패 처리와 동일한 사용자 피드백을 사용한다. | OneLink 생성 실패 시 `I18n.MemberChannel.shareLinkCreateFailed` 기반 popup이 표시된다. | `P1-T2` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | iPhone | iPad | 비고 |
|
||||
|---|---:|---:|---|
|
||||
| 내 채널 공유 버튼 | 전체 | 전체 | 기존 title bar layout 범위 안에서 표시 |
|
||||
| 다른 사람 채널 더보기 공유 | 전체 | 전체 | 기존 overlay 메뉴 패턴 유지 |
|
||||
| ActivityViewController | 전체 | 전체 | 기존 `ActivityViewController` wrapper 사용 |
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- 공유 버튼 asset은 `ic_audio_content_share`로 고정한다.
|
||||
- 신규 공용 component를 만들기보다 기존 `CreatorChannelTitleBar`, `ProfileReportMenuView`, `ActivityViewController` 패턴을 우선 재사용한다.
|
||||
- 더보기 메뉴의 신규 항목 텍스트는 `채널 공유` 의미의 I18n 항목으로 제공한다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- 홈 API 응답을 받기 전에는 공유 대상 creator 정보가 없으므로 공유 action은 실행하지 않는다.
|
||||
- 공유 시트 표시 중 기존 loading overlay를 새로 추가하지 않는다.
|
||||
- OneLink 생성 실패 시 기존 popup 피드백을 표시한다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- 공유 버튼에는 채널 공유 의미의 accessibility label을 제공한다.
|
||||
- 더보기 메뉴의 `채널 공유` 항목은 탭 가능한 충분한 영역을 유지한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
신규 API 계약은 없다.
|
||||
|
||||
공유 링크 생성 파라미터:
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `af_dp` | `voiceon://` |
|
||||
| `deep_link_value` | `channel` |
|
||||
| `deep_link_sub5` | `{creatorId}` |
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 공유 메시지에는 기존 채널 공유와 동일하게 공개 가능한 채널 nickname과 OneLink URL만 포함한다.
|
||||
- 토큰, 인증 헤더, 개인정보를 공유 메시지나 로그에 포함하지 않는다.
|
||||
- 새 네트워크 요청을 추가하지 않는다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 공유 버튼 또는 메뉴 항목 탭 한 번으로 iOS 공유 시트가 표시되어야 한다.
|
||||
- 기존 팔로우/알림/차단/신고 title bar 및 더보기 흐름을 회귀시키지 않는다.
|
||||
- 테스트 타깃이 없는 현재 저장소 상태를 고려해, 구현 시 focused 빌드와 수동 검증을 완료 증거로 기록한다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [ ] 내 채널 title bar에서 `ic_audio_content_share` 버튼을 탭하면 공유 시트가 열린다. (`CCSHARE-001`, `CCSHARE-002`)
|
||||
- [ ] 다른 사람 채널 더보기 메뉴 최상단 `채널 공유`를 탭하면 공유 시트가 열린다. (`CCSHARE-003`~`CCSHARE-005`)
|
||||
- [x] 공유 메시지와 OneLink 파라미터가 기존 `UserProfileViewModel.shareChannel` 기준과 일치한다. (`CCSHARE-006`)
|
||||
- [x] OneLink 생성 실패 시 기존 실패 popup과 동일한 피드백이 표시된다. (`CCSHARE-007`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 내 채널 공유 버튼은 우측 상단 title bar에 직접 노출된다.
|
||||
- [x] 다른 사람 채널 메뉴에서 `채널 공유`는 최상단에 표시된다.
|
||||
- [x] 기존 차단/신고 메뉴 항목과 동작은 유지된다.
|
||||
|
||||
### 14.3 추적성 완료 기준
|
||||
|
||||
- [x] 모든 확정 요구사항이 `plan-task.md`의 Task와 연결된다.
|
||||
- [x] 구현 전 코드 변경 범위가 `SodaLive/Sources/V2/CreatorChannel/**`, `SodaLive/Sources/Report/ProfileReportMenuView.swift`, `SodaLive/Sources/I18n/I18n.swift` 중심인지 확인한다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 없음 | 확정 | 추가 미결정 사항 없음 | 기존 공유 방식 재사용 | 사용자 | 2026-09-14 | 전체 |
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `CCSHARE-001~002` | 해당 없음 | 1 | `P1-T1`, `P1-T2` | 빌드 | 내 채널 공유 버튼 탭 |
|
||||
| `CCSHARE-003~005` | 해당 없음 | 1 | `P1-T3` | 빌드 | 다른 사람 채널 더보기 공유 탭 |
|
||||
| `CCSHARE-006~007` | 해당 없음 | 1 | `P1-T2`, `P1-GATE` | 코드 대조, 빌드 | 공유 메시지와 실패 popup 확인 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | 기존 문서에 누적하지 않고 신규 문서 `docs/20260914_크리에이터_채널_공유/`를 만든다. | 사용자 선택 B | 전체 |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 다른 사람 채널 더보기 메뉴의 `채널 공유` 항목은 최상단에 배치한다. | 사용자 선택 A | `CCSHARE-004`, `P1-T3` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 공유 구현은 기존 `UserProfileView`의 `ActivityViewController` 방식과 OneLink 파라미터를 재사용한다. | 사용자 요구사항, 기존 코드 확인 | `CCSHARE-002`, `CCSHARE-006` |
|
||||
|
||||
## 18. 변경 관리
|
||||
|
||||
- 공유 문구, 딥링크 파라미터, 메뉴 순서가 바뀌면 Decision Log에 정정 행을 추가한 뒤 요구사항과 `plan-task.md`를 갱신한다.
|
||||
- 구현 중 테스트 타깃 또는 빌드 명령이 달라지면 `docs/agent-guides/build-test-verification.md` 확인 후 검증 기록에 실제 명령을 남긴다.
|
||||
@@ -168,6 +168,7 @@ SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와
|
||||
- 메인 컨테이너는 접근안 B에 따라 신규 구현 후 기존 미니 플레이어, 인증, 팝업 등 필요한 주변 기능을 이관한다.
|
||||
- 크리에이터용 생성 메뉴는 내 채널 홈의 UI와 action을 복제하지 않고 공용 컴포넌트와 기존 `AppStep` route를 재사용한다.
|
||||
- 메인 홈에서의 크리에이터 판별은 별도 API 없이 저장된 `MemberRole.CREATOR` 값을 사용한다.
|
||||
- 크리에이터 계정의 메인 `채팅` 탭 우측 하단 신규 대화 버튼은 `ic_new_talk_add`를 사용하며, 기존 표시 조건과 action은 유지한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user