30 Commits
Author SHA1 Message Date
klaus 0fb727d0ee chore(app): versionCode를 254, 앱 버전을 1.56.3으로 올린다 2026-09-18 15:30:57 +09:00
klaus 48f2a0e543 docs(main): V2 직접 진입 리뷰를 기록한다 2026-09-18 14:49:10 +09:00
klaus c261b32148 docs(main): V2 직접 진입 문서를 정리한다 2026-09-18 14:49:00 +09:00
klaus cc8f271f44 test(chat): 대화 title bar 회귀 기준을 갱신한다 2026-09-18 14:48:54 +09:00
klaus 086af8a366 feat(main): 라이브룸 딥링크 지연 처리를 추가한다 2026-09-18 14:48:47 +09:00
klaus 5e114e18a0 feat(main): 레거시 직접 진입을 V2 메인으로 전환한다 2026-09-18 14:48:39 +09:00
klaus 330148821c chore(app): versionCode를 253으로 올린다 2026-09-17 14:45:04 +09:00
klaus 1d38912474 feat(creator): 커뮤니티 상세 작성자 채널 이동을 추가한다 2026-09-17 14:33:18 +09:00
klaus 2e67e092a5 docs(chat): 신규 DM 아이콘 변경 기록을 남긴다 2026-09-17 12:29:07 +09:00
klaus 8749ae9ae7 feat(chat): 신규 DM 버튼 아이콘을 교체한다 2026-09-17 12:29:00 +09:00
klaus 81b5a1dafc fix(chat): 대화 탭 검색 액션을 제거한다 2026-09-17 12:28:50 +09:00
klaus 582b4fe703 style(main): import 정렬을 정리한다 2026-09-17 12:28:45 +09:00
klaus 287ba0b01b chore(app): versionCode를 252로 올린다 2026-09-17 12:28:41 +09:00
klaus 7b11185db4 chore(app): versionCode를 251, 앱 버전을 1.56.2로 올린다 2026-09-15 14:12:14 +09:00
klaus 5029332023 docs(creator): 채널 공유 검증 기록을 남긴다 2026-09-15 11:46:09 +09:00
klaus f891c15eb9 test(creator): 후원 can 설정을 격리한다 2026-09-15 11:45:59 +09:00
klaus 9bb0a6512e test(common): can 테스트 helper를 추가한다 2026-09-15 11:45:51 +09:00
klaus 4bc725bdf2 feat(creator): 채널 공유 진입점을 추가한다 2026-09-15 11:45:28 +09:00
klaus 5e1a7e9ace docs(creator): 채널 공유 요구사항을 정리한다 2026-09-15 11:45:20 +09:00
klaus e57998654b docs(dm): 수신자 선택 라벨 요구사항을 기록한다 2026-09-14 22:51:55 +09:00
klaus 7c2ea3a02b fix(dm): 수신자 선택 라벨 표시를 정리한다 2026-09-14 22:51:40 +09:00
klaus a6c63b71b9 docs(chat): 수신자 선택 리뷰를 기록한다 2026-09-14 22:05:20 +09:00
klaus 4f6b235d60 feat(chat): 대화 탭 DM 시작 진입점을 추가한다 2026-09-14 22:05:12 +09:00
klaus 127e5fc147 feat(dm): 수신자 선택 화면을 연결한다 2026-09-14 22:05:05 +09:00
klaus be847b4600 feat(dm): 수신자 목록 행을 추가한다 2026-09-14 22:04:57 +09:00
klaus c6bee79f8e feat(dm): 수신자 선택 문구를 추가한다 2026-09-14 22:04:51 +09:00
klaus e9bb2c6dd8 feat(dm): 수신자 선택 상태를 추가한다 2026-09-14 22:04:45 +09:00
klaus 9de82916dd feat(dm): recipientId 계약을 추가한다 2026-09-14 22:04:40 +09:00
klaus f454596df5 docs(chat): 수신자 DM 계획을 정리한다 2026-09-14 22:04:32 +09:00
klaus 22a24cb1c1 docs(chat): 수신자 DM 요구사항을 정리한다 2026-09-14 22:04:26 +09:00
59 changed files with 3666 additions and 101 deletions
+2 -2
View File
@@ -86,8 +86,8 @@ android {
applicationId "kr.co.vividnext.sodalive"
minSdk 23
targetSdk 35
versionCode 250
versionName "1.56.1"
versionCode 254
versionName "1.56.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+5 -2
View File
@@ -68,6 +68,9 @@
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup"
tools:targetApi="31">
<activity
android:name="kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity"
android:exported="false" />
<activity
android:name=".main.DeepLinkActivity"
android:exported="true">
@@ -174,8 +177,8 @@
<activity android:name=".settings.notification.NotificationSettingsActivity" />
<activity android:name=".settings.notification.NotificationReceiveSettingsActivity" />
<activity android:name=".settings.ContentSettingsActivity" />
<activity android:name=".live.reservation_status.LiveReservationStatusActivity" />
<activity android:name=".live.reservation_status.LiveReservationCancelActivity" />
<activity android:name=".live.reservation.status.LiveReservationStatusActivity" />
<activity android:name=".live.reservation.status.LiveReservationCancelActivity" />
<activity android:name=".audio_content.AudioContentActivity" />
<activity android:name=".audio_content.detail.AudioContentDetailActivity" />
<activity android:name=".audio_content.modify.AudioContentModifyActivity" />
@@ -92,6 +92,28 @@ object SharedPreferenceManager {
}
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetCanForTest() {
ensureInitialized()
runBlocking {
dataStore.edit { preferences ->
removeByName(preferences, Constants.PREF_CAN)
}
}
preferenceState.update { state -> state - Constants.PREF_CAN }
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setCanForTest(value: Int) {
ensureInitialized()
runBlocking {
dataStore.edit { preferences ->
preferences[intPreferencesKey(Constants.PREF_CAN)] = value
}
}
preferenceState.update { state -> state + (Constants.PREF_CAN to value) }
}
fun clear() {
ensureInitialized()
@@ -96,7 +96,7 @@ import kr.co.vividnext.sodalive.live.LiveRepository
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendApi
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepository
import kr.co.vividnext.sodalive.live.reservation_status.LiveReservationStatusViewModel
import kr.co.vividnext.sodalive.live.reservation.status.LiveReservationStatusViewModel
import kr.co.vividnext.sodalive.live.room.LiveRoomViewModel
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateViewModel
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailViewModel
@@ -5,8 +5,8 @@ import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse
import kr.co.vividnext.sodalive.live.reservation_status.CancelLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation_status.GetLiveReservationResponse
import kr.co.vividnext.sodalive.live.reservation.status.CancelLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation.status.GetLiveReservationResponse
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
@@ -4,7 +4,7 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation_status.CancelLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation.status.CancelLiveReservationRequest
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import android.os.Parcelable
import androidx.annotation.Keep
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import android.annotation.SuppressLint
import android.content.Intent
@@ -18,10 +18,10 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCancelBinding
import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.can.status.CanStatusActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
import java.util.Locale
import java.util.TimeZone
@@ -117,7 +117,7 @@ class LiveReservationCancelActivity : BaseActivity<ActivityLiveReservationCancel
binding.tvOtherSudaReservation.setOnClickListener {
finishAffinity()
startActivity(Intent(applicationContext, MainActivity::class.java))
startActivity(Intent(applicationContext, MainV2Activity::class.java))
}
binding.tvCheckCanStatus.setOnClickListener {
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import android.annotation.SuppressLint
import android.app.Activity
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import android.view.LayoutInflater
import android.view.View
@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.live.reservation_status
package kr.co.vividnext.sodalive.live.reservation.status
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@@ -120,13 +120,13 @@ import kr.co.vividnext.sodalive.live.roulette.RoulettePreviewDialog
import kr.co.vividnext.sodalive.live.roulette.RouletteSpinDialog
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
import kr.co.vividnext.sodalive.main.DeepLinkActivity
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.json.JSONObject
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit
@@ -379,7 +379,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
showDeepLinkNavigationDialog {
val nextIntent = Intent(applicationContext, MainActivity::class.java).apply {
val nextIntent = Intent(applicationContext, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(Constants.EXTRA_DATA, bundle)
}
@@ -30,6 +30,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog
@@ -230,6 +231,7 @@ class CreatorChannelActivity :
private fun setupClickListeners() {
binding.ivBack.setOnClickListener { finish() }
binding.ivMore.setOnClickListener { onMoreClicked() }
binding.ivShare.setOnClickListener { shareCurrentCreatorChannel() }
binding.tvFollowerCount.setOnClickListener { onFollowerCountClicked() }
binding.layoutFollowCapsule.setOnClickListener { onFollowCapsuleClicked() }
binding.ivBell.setOnClickListener { onBellClicked() }
@@ -291,9 +293,11 @@ class CreatorChannelActivity :
binding.layoutFollowCapsule.visibility = View.GONE
binding.ivBell.visibility = View.GONE
binding.ivMore.visibility = View.GONE
binding.ivShare.visibility = View.VISIBLE
return
}
binding.ivShare.visibility = View.GONE
binding.layoutFollowCapsule.visibility = View.VISIBLE
binding.ivMore.visibility = View.VISIBLE
val titleBarState = CreatorChannelTitleBarState.from(
@@ -435,12 +439,35 @@ class CreatorChannelActivity :
if (header.isOwner) return
CreatorChannelMoreBottomSheet.newInstance().apply {
onClickChannelShare = { shareCurrentCreatorChannel() }
onClickBlock = { showUserBlockDialog() }
onClickUserReport = { showUserReportDialog() }
onClickProfileReport = { showProfileReportDialog() }
}.show(supportFragmentManager, CreatorChannelMoreBottomSheet::class.java.simpleName)
}
private fun shareCurrentCreatorChannel() {
val header = currentHeader ?: return
if (creatorId <= 0L) return
val nickname = header.nickname
val params = mapOf(
"af_dp" to "voiceon://",
"deep_link_value" to "channel",
"deep_link_sub5" to "$creatorId"
)
val shareUrl = Utils.createOneLinkUrl(params = params)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, "소다라이브 ${nickname}님의 채널입니다.\n$shareUrl")
val shareIntent = Intent.createChooser(
intent,
getString(R.string.screen_user_profile_share_channel)
)
startActivity(shareIntent)
}
private fun showUserBlockDialog() {
val nickname = currentHeader?.nickname.orEmpty()
V2ModalDialog(
@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.databinding.DialogCreatorChannelMoreBinding
class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
var onClickChannelShare: (() -> Unit)? = null
var onClickBlock: (() -> Unit)? = null
var onClickUserReport: (() -> Unit)? = null
var onClickProfileReport: (() -> Unit)? = null
@@ -29,6 +30,10 @@ class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
super.onViewCreated(view, savedInstanceState)
val viewBinding = binding ?: return
viewBinding.tvChannelShare.setOnClickListener {
dismiss()
onClickChannelShare?.invoke()
}
viewBinding.tvUserBlock.setOnClickListener {
dismiss()
onClickBlock?.invoke()
@@ -35,6 +35,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.Creato
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
@@ -174,6 +175,11 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
tvCreatorChannelCommunityDetailTime.text =
formatUtcRelativeTimeText(tvCreatorChannelCommunityDetailTime.context, post.createdAtUtc)
layoutCreatorChannelCommunityDetailCreator.setOnClickListener {
startActivity(
CreatorChannelActivity.newIntent(this@CreatorChannelCommunityDetailActivity, post.creatorId)
)
}
btnCreatorChannelCommunityDetailMore.isVisible = post.showMore
tvCreatorChannelCommunityDetailBody.text = post.content
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
@@ -1,13 +1,13 @@
package kr.co.vividnext.sodalive.v2.main
import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
@@ -38,6 +38,8 @@ import com.google.firebase.messaging.FirebaseMessaging
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.orhanobut.logger.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
@@ -53,6 +55,7 @@ import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
import kr.co.vividnext.sodalive.message.MessageActivity
@@ -79,9 +82,6 @@ import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
import kr.co.vividnext.sodalive.v2.main.home.HomeMainFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.Locale
import kotlin.math.max
@@ -108,6 +108,18 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private val deferredRouteRunnable = Runnable {
if (isFinishing || isDestroyed) return@Runnable
try {
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
executeDeeplink(intent)
}
} finally {
isDeferredRouteLoading = false
updateRouteLoadingDialog()
}
}
private val audioContentReceiver = AudioContentReceiver()
private var playerStateJob: Job? = null
private var isDeferredRouteLoading = false
@@ -145,13 +157,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
trackAppLaunchIfNeeded()
pushTokenUpdate()
handler.postDelayed({
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
executeDeeplink(intent)
}
isDeferredRouteLoading = false
updateRouteLoadingDialog()
}, 1000)
handler.postDelayed(deferredRouteRunnable, 1000)
if (isV2AccessAllowed(AccessRequirement.Login)) {
updatePidAndGaid()
@@ -178,15 +184,27 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handler.removeCallbacks(deferredRouteRunnable)
isDeferredRouteLoading = false
updateRouteLoadingDialog()
if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
openChatWithInitialFilter()
return
}
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
executeDeeplink(intent)
if (handleAudioNotificationRoute(intent) || !isV2AccessAllowed(AccessRequirement.Login)) {
return
}
if (LiveRoomActivity.isForeground && isLiveRoomDeepLink(intent)) {
isDeferredRouteLoading = true
updateRouteLoadingDialog()
handler.postDelayed(deferredRouteRunnable, 1000)
return
}
executeDeeplink(intent)
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@@ -213,6 +231,9 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
}
override fun onDestroy() {
handler.removeCallbacks(deferredRouteRunnable)
isDeferredRouteLoading = false
routeLoadingDialog.dismiss()
deInitMiniPlayer()
playerStateJob?.cancel()
super.onDestroy()
@@ -315,15 +336,18 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
viewModel.clickTab(MainV2Tab.HOME)
true
}
R.id.menu_main_v2_content -> {
viewModel.clickTab(MainV2Tab.CONTENT)
true
}
R.id.menu_main_v2_chat -> selectChatTabWithLoginGuard()
R.id.menu_main_v2_my -> {
viewModel.clickTab(MainV2Tab.MY)
true
}
else -> false
}
}
@@ -757,19 +781,53 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
}
private fun executeDeeplink(intent: Intent) {
val bundle = intent.getBundleExtra(Constants.EXTRA_DATA) ?: return
val routeBundle = resolveDeepLinkBundle(intent) ?: return
if (executeBundleRoute(routeBundle)) {
clearDeferredDeepLink()
}
}
private fun resolveDeepLinkBundle(intent: Intent): Bundle? {
val bundle = intent.getBundleExtra(Constants.EXTRA_DATA) ?: return null
val deepLinkUrl = bundle.getString("deep_link")
val routeBundle = if (!deepLinkUrl.isNullOrBlank()) {
return if (!deepLinkUrl.isNullOrBlank()) {
Bundle(bundle).apply {
buildBundleFromDeepLinkUrl(deepLinkUrl)?.let { putAll(it) }
}
} else {
bundle
}
}
if (executeBundleRoute(routeBundle)) {
clearDeferredDeepLink()
private fun isLiveRoomDeepLink(intent: Intent): Boolean {
val bundle = resolveDeepLinkBundle(intent) ?: return false
if (isDmChatDeepLink(bundle)) return false
val roomId = bundle.getString("room_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_ROOM_ID).takeIf { it > 0 }
if (roomId != null && roomId > 0) return true
if (hasDirectNonLiveRoute(bundle)) return false
val deepLinkValueId = bundle.getString("deep_link_sub5")?.toLongOrNull()
return bundle.getString("deep_link_value")?.lowercase(Locale.ROOT) == "live" &&
deepLinkValueId != null && deepLinkValueId > 0
}
private fun hasDirectNonLiveRoute(bundle: Bundle): Boolean {
fun hasPositiveId(stringKey: String, longKey: String): Boolean {
return (
bundle.getString(stringKey)?.toLongOrNull()
?: bundle.getLong(longKey).takeIf { it > 0 }
)?.let { it > 0 } == true
}
return hasPositiveId(Constants.EXTRA_COMMUNITY_POST_ID, Constants.EXTRA_COMMUNITY_POST_ID) ||
hasPositiveId("channel_id", Constants.EXTRA_USER_ID) ||
hasPositiveId("content_id", Constants.EXTRA_AUDIO_CONTENT_ID) ||
hasPositiveId("message_id", Constants.EXTRA_MESSAGE_ID) ||
hasPositiveId(Constants.EXTRA_COMMUNITY_CREATOR_ID, Constants.EXTRA_COMMUNITY_CREATOR_ID) ||
hasPositiveId("audition_id", Constants.EXTRA_AUDITION_ID)
}
private fun buildBundleFromDeepLinkUrl(deepLinkUrl: String): Bundle? {
@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.v2.main.chat
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
@@ -12,9 +10,10 @@ import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
@@ -24,6 +23,7 @@ import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
import kr.co.vividnext.sodalive.v2.main.chat.model.emptyMessageResId
import kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -51,12 +51,19 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
private var isChatPullRefreshing = false
private var refreshingChatFilter: ChatRoomFilter? = null
private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null
private var shouldRefreshChatRoomsOnResume = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
shouldRefreshChatRoomsOnResume = false
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupTitleBar()
val isCreator = SharedPreferenceManager.role == MemberRole.CREATOR.name
binding.btnNewDm.isVisible = isCreator
binding.btnNewDm.setOnClickListener {
if (isCreator) openWithLoginGuard(DmRecipientPickerActivity::class.java)
}
val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL
selectedFilter = initialFilter
setupFilterTabs(initialFilter)
@@ -67,6 +74,17 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
viewModel.loadFirstPage(initialFilter)
}
override fun onResume() {
super.onResume()
if (!shouldRefreshChatRoomsOnResume) {
shouldRefreshChatRoomsOnResume = true
return
}
cancelChatPullRefresh()
bindChatRooms(emptyList(), showEmpty = false)
viewModel.loadFirstPage(selectedFilter)
}
fun selectFilter(filter: ChatRoomFilter) {
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
cancelChatPullRefresh()
@@ -82,20 +100,6 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
binding.viewChatTitleBar.ivTitleBarMenu.setOnClickListener {
openWithLoginGuard(CanChargeActivity::class.java)
}
val searchAction = ImageView(requireContext()).apply {
setImageResource(R.drawable.ic_bar_search)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
marginStart = resources.getDimensionPixelSize(R.dimen.spacing_14)
}
}
searchAction.setOnClickListener {
openWithLoginGuard(SearchActivity::class.java)
}
binding.viewChatTitleBar.llTitleBarActions.addView(searchAction)
}
private fun openWithLoginGuard(activityClass: Class<*>) {
@@ -174,6 +178,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
binding.rvChatRooms.scrollToPosition(0)
}
}
ChatRoomListUiState.Empty -> {
lastRenderedChatContentState = null
bindChatRooms(emptyList(), showEmpty = true)
@@ -182,6 +187,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
refreshingChatFilter = null
}
}
is ChatRoomListUiState.Error -> {
lastRenderedChatContentState = null
if (!isChatPullRefreshing || !isRefreshForCurrentFilter) {
@@ -192,6 +198,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
refreshingChatFilter = null
}
}
ChatRoomListUiState.Loading -> {
if (!isChatPullRefreshing) {
binding.tvChatEmptyMessage.isVisible = false
@@ -5,7 +5,8 @@ import com.google.gson.annotations.SerializedName
@Keep
data class CreateDmChatRoomRequest(
@SerializedName("creatorId") val creatorId: Long
@SerializedName("recipientId") val recipientId: Long? = null,
@SerializedName("creatorId") val creatorId: Long? = null
)
@Keep
@@ -18,6 +18,14 @@ class DmChatRepository(
request = CreateDmChatRoomRequest(creatorId = creatorId)
)
fun createOrGetRoomByRecipientId(
token: String,
recipientId: Long
): Single<ApiResponse<CreateDmChatRoomResponse>> = api.createDmChatRoom(
authHeader = bearer(token),
request = CreateDmChatRoomRequest(recipientId = recipientId)
)
fun openRoom(
token: String,
roomId: Long,
@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.databinding.ItemDmRecipientBinding
import kr.co.vividnext.sodalive.v2.main.home.ui.loadHomeCreatorProfileImage
class DmRecipientAdapter(
private val onClick: (DmRecipientUiModel) -> Unit
) : RecyclerView.Adapter<DmRecipientAdapter.RecipientViewHolder>() {
private var items: List<DmRecipientUiModel> = emptyList()
fun submitItems(items: List<DmRecipientUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun getItemCount(): Int = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipientViewHolder =
RecipientViewHolder(ItemDmRecipientBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: RecipientViewHolder, position: Int) {
holder.bind(items[position])
}
inner class RecipientViewHolder(private val binding: ItemDmRecipientBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: DmRecipientUiModel) {
binding.ivProfile.loadHomeCreatorProfileImage(item.profileImageUrl)
binding.tvNickname.text = item.nickname
binding.root.setOnClickListener { onClick(item) }
}
}
}
@@ -0,0 +1,113 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityDmRecipientPickerBinding
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.user.UserRepository
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
import org.koin.android.ext.android.inject
class DmRecipientPickerActivity : BaseActivity<ActivityDmRecipientPickerBinding>(
ActivityDmRecipientPickerBinding::inflate
) {
private val explorerRepository: ExplorerRepository by inject()
private val userRepository: UserRepository by inject()
private val dmRepository: DmChatRepository by inject()
private val viewModel: DmRecipientPickerViewModel by viewModels {
dmRecipientPickerFactory(explorerRepository, userRepository)
}
private val adapter = DmRecipientAdapter(::confirmRecipient)
private val loadingDialog by lazy { LoadingDialog(this, layoutInflater) }
private var isOpeningRoom = false
override fun setupView() {
if (SharedPreferenceManager.role != MemberRole.CREATOR.name) {
finish()
return
}
binding.btnBack.setOnClickListener { finish() }
binding.rvRecipients.layoutManager = LinearLayoutManager(this)
binding.rvRecipients.adapter = adapter
binding.etSearchNickname.doAfterTextChanged {
viewModel.onSearchQueryChanged(it?.toString().orEmpty())
}
viewModel.recipientStateLiveData.observe(this) { state ->
adapter.submitItems(state.recipients)
binding.tvFollowers.isVisible = state.mode == DmRecipientPickerMode.FOLLOWERS && state.recipients.isNotEmpty()
binding.tvSearchResultCount.isVisible = state.mode == DmRecipientPickerMode.SEARCH
val searchResultCount = state.searchResultCount.toString()
val searchResultText = getString(
R.string.dm_recipient_search_result_count,
state.searchResultCount
)
binding.tvSearchResultCount.text = SpannableString(searchResultText).apply {
val start = searchResultText.lastIndexOf(searchResultCount)
setSpan(
ForegroundColorSpan(ContextCompat.getColor(this@DmRecipientPickerActivity, R.color.gray_500)),
start,
start + searchResultCount.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
binding.tvSearchEmpty.isVisible = state.isSearchEmpty
binding.rvRecipients.isVisible = !state.isSearchEmpty
if (state.isError) showToast(getString(R.string.common_error_request))
}
viewModel.enter()
}
private fun confirmRecipient(recipient: DmRecipientUiModel) {
if (isOpeningRoom) return
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.dm_recipient_confirm_title),
desc = getString(R.string.dm_recipient_confirm_message, recipient.nickname),
cancelButtonTitle = getString(R.string.cancel),
confirmButtonTitle = getString(R.string.dm_recipient_send),
confirmButtonClick = { openRoom(recipient.id) }
).show(screenWidth)
}
private fun openRoom(recipientId: Long) {
if (isOpeningRoom) return
isOpeningRoom = true
loadingDialog.show(screenWidth)
compositeDisposable.add(
dmRepository.createOrGetRoomByRecipientId(SharedPreferenceManager.token, recipientId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally {
isOpeningRoom = false
loadingDialog.dismiss()
}
.subscribe({ response ->
val data = response.data
if (response.success && data != null) {
startActivity(DmChatRoomActivity.newIntentByRoomId(this, data.roomId))
finish()
} else {
showToast(getString(R.string.common_error_request))
}
}, {
showToast(getString(R.string.common_error_request))
})
)
}
}
@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.user.UserRepository
internal fun dmRecipientPickerFactory(
explorerRepository: ExplorerRepository,
userRepository: UserRepository
) = viewModelFactory {
initializer {
DmRecipientPickerViewModel(
loadFollowers = {
explorerRepository.getFollowerList(
userId = SharedPreferenceManager.userId,
page = 1,
size = 20,
token = "Bearer ${SharedPreferenceManager.token}"
).map { response ->
val data = response.data
check(response.success && data != null)
data.items.map { DmRecipientUiModel(it.userId, it.nickname, it.profileImage) }
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
},
searchUsers = { nickname ->
userRepository.searchUser(nickname, "Bearer ${SharedPreferenceManager.token}")
.map { response ->
val data = response.data
check(response.success && data != null)
data.map { DmRecipientUiModel(it.id, it.nickname, it.profileImageUrl) }
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
},
debounceScheduler = AndroidSchedulers.mainThread()
)
}
}
@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.disposables.SerialDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import java.util.concurrent.TimeUnit
data class DmRecipientUiModel(
val id: Long,
val nickname: String,
val profileImageUrl: String
)
enum class DmRecipientPickerMode {
FOLLOWERS,
SEARCH
}
data class DmRecipientPickerUiState(
val mode: DmRecipientPickerMode = DmRecipientPickerMode.FOLLOWERS,
val recipients: List<DmRecipientUiModel> = emptyList(),
val searchResultCount: Int = 0,
val isSearchEmpty: Boolean = false,
val isError: Boolean = false
)
class DmRecipientPickerViewModel(
private val loadFollowers: () -> Single<List<DmRecipientUiModel>>,
private val searchUsers: (String) -> Single<List<DmRecipientUiModel>>,
private val debounceScheduler: Scheduler = Schedulers.computation()
) : BaseViewModel() {
private val _recipientStateLiveData = MutableLiveData(DmRecipientPickerUiState())
val recipientStateLiveData: LiveData<DmRecipientPickerUiState> get() = _recipientStateLiveData
private var searchDisposable: Disposable? = null
private val requestDisposable = SerialDisposable().also(compositeDisposable::add)
fun enter() {
requestDisposable.set(null)
requestDisposable.set(
loadFollowers()
.subscribe({ recipients ->
_recipientStateLiveData.value = DmRecipientPickerUiState(
mode = DmRecipientPickerMode.FOLLOWERS,
recipients = recipients
)
}, {
_recipientStateLiveData.value = DmRecipientPickerUiState(isError = true)
})
)
}
fun onSearchQueryChanged(query: String) {
val keyword = query.trim()
searchDisposable?.dispose()
requestDisposable.set(null)
if (keyword.length < MIN_SEARCH_QUERY_LENGTH) {
enter()
return
}
val scheduledSearch = debounceScheduler.scheduleDirect(
{
requestDisposable.set(
searchUsers(keyword).subscribe({ recipients ->
_recipientStateLiveData.value = DmRecipientPickerUiState(
mode = DmRecipientPickerMode.SEARCH,
recipients = recipients,
searchResultCount = recipients.size,
isSearchEmpty = recipients.isEmpty()
)
}, {
_recipientStateLiveData.value = DmRecipientPickerUiState(
mode = DmRecipientPickerMode.SEARCH,
isError = true
)
})
)
},
SEARCH_DEBOUNCE_MILLIS,
TimeUnit.MILLISECONDS
)
searchDisposable = scheduledSearch
compositeDisposable.add(scheduledSearch)
}
private companion object {
const val MIN_SEARCH_QUERY_LENGTH = 2
const val SEARCH_DEBOUNCE_MILLIS = 500L
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -226,6 +226,14 @@
tools:src="@drawable/ic_bar_bell"
tools:visibility="visible" />
<ImageView
android:id="@+id/iv_share"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/screen_user_profile_share_channel"
android:src="@drawable/ic_audio_content_share"
android:visibility="gone" />
<ImageView
android:id="@+id/iv_more"
android:layout_width="24dp"
@@ -53,43 +53,53 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_creator_channel_community_detail_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_placeholder" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_nickname"
style="@style/Typography.Body5"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_community_detail_creator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/btn_creator_channel_community_detail_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:text="크리에이터" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_creator_channel_community_detail_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_detail_nickname"
tools:text="2분 전" />
<ImageView
android:id="@+id/iv_creator_channel_community_detail_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_placeholder" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:text="크리에이터" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_detail_nickname"
tools:text="2분 전" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageButton
android:id="@+id/btn_creator_channel_community_detail_more"
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="54dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/btn_back"
android:layout_width="42dp"
android:layout_height="match_parent"
android:contentDescription="@null"
android:padding="@dimen/spacing_12"
android:src="@drawable/ic_back" />
<LinearLayout
android:id="@+id/search_container"
android:layout_width="0dp"
android:layout_height="42dp"
android:layout_marginEnd="@dimen/spacing_14"
android:layout_weight="1"
android:background="@drawable/bg_round_corner_10_232323"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_12">
<ImageView
android:id="@+id/iv_search"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_title_search_black" />
<EditText
android:id="@+id/et_search_nickname"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:background="@null"
android:hint="@string/dm_recipient_search_hint"
android:importantForAutofill="no"
android:inputType="textPersonName"
android:maxLines="1"
android:padding="0dp"
android:textColor="@color/white"
android:textColorHint="@color/color_979797"
android:textCursorDrawable="@drawable/edit_text_cursor"
tools:ignore="LabelFor" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_followers"
style="@style/Typography.Body1"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="center_vertical"
android:paddingHorizontal="@dimen/spacing_14"
android:text="@string/dm_recipient_followers"
android:textColor="@color/color_e2e2e2" />
<TextView
android:id="@+id/tv_search_result_count"
style="@style/Typography.Body1"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="center_vertical"
android:paddingHorizontal="@dimen/spacing_14"
android:text="@string/dm_recipient_search_result_count"
android:textColor="@color/white"
android:visibility="gone"
tools:text="검색결과 0" />
<FrameLayout
android:id="@+id/recipient_content_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_recipients"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tv_search_empty"
style="@style/Typography.Body5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="@dimen/spacing_20"
android:text="@string/dm_recipient_search_empty"
android:textColor="@color/color_777777"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
@@ -16,6 +16,16 @@
android:layout_marginBottom="12dp"
android:background="@drawable/bg_bottom_sheet_handle" />
<TextView
android:id="@+id/tv_channel_share"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:paddingVertical="16dp"
android:text="@string/screen_user_profile_share_channel"
android:textColor="@color/white"
android:textSize="18sp" />
<TextView
android:id="@+id/tv_user_block"
android:layout_width="match_parent"
@@ -58,4 +58,17 @@
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ImageButton
android:id="@+id/btn_new_dm"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_margin="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:contentDescription="@string/screen_chat_floating_button"
android:elevation="8dp"
android:padding="@dimen/spacing_14"
android:src="@drawable/ic_new_talk_add"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="66dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14">
<ImageView
android:id="@+id/iv_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@drawable/bg_round_corner_999_263238"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tv_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white" />
</LinearLayout>
+7
View File
@@ -550,6 +550,13 @@
<string name="screen_voice_message_write_permission_denied">To use voice messages, please allow audio recording permission.</string>
<string name="screen_message_recipient_title">Search recipient</string>
<string name="screen_message_recipient_search_hint">Search</string>
<string name="dm_recipient_search_hint">Enter a fan name</string>
<string name="dm_recipient_followers">Followers</string>
<string name="dm_recipient_search_result_count">Search results %1$d</string>
<string name="dm_recipient_search_empty">No users found.\nTry searching with another name.</string>
<string name="dm_recipient_confirm_title">Send message</string>
<string name="dm_recipient_confirm_message">Send a message to %1$s?</string>
<string name="dm_recipient_send">Send</string>
<string name="screen_live_loading">oading live broadcast…</string>
<string name="screen_live_now_title_prefix">Live</string>
<string name="screen_live_now_title_suffix">now</string>
+7
View File
@@ -550,6 +550,13 @@
<string name="screen_voice_message_write_permission_denied">音声メッセージを利用するには録音権限が必要です。</string>
<string name="screen_message_recipient_title">受信者検索</string>
<string name="screen_message_recipient_search_hint">検索</string>
<string name="dm_recipient_search_hint">ファンの名前を入力してください</string>
<string name="dm_recipient_followers">フォロワー</string>
<string name="dm_recipient_search_result_count">検索結果 %1$d</string>
<string name="dm_recipient_search_empty">ユーザーが見つかりません。\n別の名前でもう一度検索してください。</string>
<string name="dm_recipient_confirm_title">メッセージを送る</string>
<string name="dm_recipient_confirm_message">%1$sにメッセージを送りますか?</string>
<string name="dm_recipient_send">送る</string>
<string name="screen_live_loading">ライブを読み込み中。</string>
<string name="screen_live_now_title_prefix">今</string>
<string name="screen_live_now_title_suffix">配信中</string>
+7
View File
@@ -549,6 +549,13 @@
<string name="screen_voice_message_write_permission_denied">오디오 녹음 권한을 거부하시면 음성 메시지를 이용하실 수 없습니다.</string>
<string name="screen_message_recipient_title">받는 사람 검색</string>
<string name="screen_message_recipient_search_hint">검색</string>
<string name="dm_recipient_search_hint">팬 이름을 입력하세요</string>
<string name="dm_recipient_followers">팔로워</string>
<string name="dm_recipient_search_result_count">검색결과 %1$d</string>
<string name="dm_recipient_search_empty">사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.</string>
<string name="dm_recipient_confirm_title">메시지 보내기</string>
<string name="dm_recipient_confirm_message">%1$s에게 메시지를 보낼까요?</string>
<string name="dm_recipient_send">보내기</string>
<string name="screen_live_loading">라이브를 불러오고 있습니다.</string>
<string name="screen_live_now_title_prefix">지금</string>
<string name="screen_live_now_title_suffix">라이브중</string>
@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.common
import android.app.Application
import android.content.Context
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class SharedPreferenceManagerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.resetCanForTest()
}
@After
fun tearDown() {
SharedPreferenceManager.resetCanForTest()
SharedPreferenceManager.resetForTest()
}
@Test
fun `resetCanForTest는 저장된 can 값을 초기화한다`() {
SharedPreferenceManager.setCanForTest(200)
SharedPreferenceManager.resetCanForTest()
assertEquals(0, SharedPreferenceManager.can)
}
@Test
fun `setCanForTest는 can 값을 동기적으로 저장한다`() {
SharedPreferenceManager.setCanForTest(200)
assertEquals(200, SharedPreferenceManager.can)
runBlocking {
val preferences = AppPreferencesDataStoreProvider.get(context).data.first()
assertEquals(200, preferences[intPreferencesKey(Constants.PREF_CAN)])
}
}
}
@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.live
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class LiveReservationPackageSourceTest {
@Test
fun `라이브 예약 현황 소스 경로는 패키지 구조와 일치한다`() {
val expectedDirectory = projectPath(
"app/src/main/java/kr/co/vividnext/sodalive/live/reservation/status"
)
val legacyDirectory = projectPath(
"app/src/main/java/kr/co/vividnext/sodalive/live/reservation_status"
)
assertTrue("Package directory not found: $expectedDirectory", expectedDirectory.isDirectory)
assertFalse("Legacy package directory remains: $legacyDirectory", legacyDirectory.exists())
}
@Test
fun `라이브 예약 현황 패키지는 underscore를 사용하지 않는다`() {
val sourceFiles = listOf(
"CancelLiveReservationRequest.kt",
"GetLiveReservationResponse.kt",
"LiveReservationCancelActivity.kt",
"LiveReservationStatusActivity.kt",
"LiveReservationStatusAdapter.kt",
"LiveReservationStatusViewModel.kt"
).map { fileName ->
projectFile("app/src/main/java/kr/co/vividnext/sodalive/live/reservation/status/$fileName")
}
sourceFiles.forEach { file ->
val source = file.readText()
assertTrue(source.contains("package kr.co.vividnext.sodalive.live.reservation.status"))
assertFalse(source.contains("package kr.co.vividnext.sodalive.live.reservation_status"))
}
}
@Test
fun `라이브 예약 현황 참조는 underscore 없는 패키지를 사용한다`() {
val checkedSources = listOf(
projectFile("app/src/main/AndroidManifest.xml"),
projectFile("app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt"),
projectFile("app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt"),
projectFile("app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt")
)
checkedSources.forEach { file ->
val source = file.readText()
assertFalse(source.contains("kr.co.vividnext.sodalive.live.reservation_status"))
assertFalse(source.contains(".live.reservation_status."))
}
val manifest = checkedSources.first().readText()
assertTrue(manifest.contains(".live.reservation.status.LiveReservationStatusActivity"))
assertTrue(manifest.contains(".live.reservation.status.LiveReservationCancelActivity"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
private fun projectPath(relativePath: String): File {
val projectRoot = listOf(File("."), File("..")).first { File(it, "app").isDirectory }
return File(projectRoot, relativePath)
}
}
@@ -55,6 +55,7 @@ class CreatorChannelHomeViewModelTest {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.resetCanForTest()
SharedPreferenceManager.token = "test-token"
repository = mock()
viewModel = CreatorChannelHomeViewModel(repository)
@@ -64,6 +65,7 @@ class CreatorChannelHomeViewModelTest {
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetCanForTest()
SharedPreferenceManager.resetForTest()
}
@@ -275,7 +277,7 @@ class CreatorChannelHomeViewModelTest {
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, Any(), null))
)
SharedPreferenceManager.can = 200
SharedPreferenceManager.setCanForTest(200)
viewModel.loadHome(100L)
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
@@ -291,7 +293,7 @@ class CreatorChannelHomeViewModelTest {
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, Any(), null))
)
SharedPreferenceManager.can = 30
SharedPreferenceManager.setCanForTest(30)
viewModel.loadHome(100L)
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class CreatorChannelShareSourceTest {
@Test
fun `내 채널은 우측 상단 공유 버튼을 표시한다`() {
val activity = projectFile(ACTIVITY_PATH).readText()
val layout = projectFile(ACTIVITY_LAYOUT_PATH).readText()
assertTrue(layout.contains("@+id/iv_share"))
assertTrue(layout.contains("android:src=\"@drawable/ic_audio_content_share\""))
assertTrue(activity.contains("binding.ivShare.visibility = View.VISIBLE"))
assertTrue(activity.contains("binding.ivShare.visibility = View.GONE"))
}
@Test
fun `다른 사람 채널 더보기는 채널 공유 항목을 제공한다`() {
val activity = projectFile(ACTIVITY_PATH).readText()
val bottomSheet = projectFile(BOTTOM_SHEET_PATH).readText()
val layout = projectFile(BOTTOM_SHEET_LAYOUT_PATH).readText()
assertTrue(layout.contains("@+id/tv_channel_share"))
assertTrue(layout.contains("@string/screen_user_profile_share_channel"))
assertTrue(bottomSheet.contains("var onClickChannelShare: (() -> Unit)? = null"))
assertTrue(bottomSheet.contains("viewBinding.tvChannelShare.setOnClickListener"))
assertTrue(activity.contains("onClickChannelShare = { shareCurrentCreatorChannel() }"))
}
@Test
fun `두 진입점은 기존 채널 공유 chooser와 메뉴 동작을 유지한다`() {
val activity = projectFile(ACTIVITY_PATH).readText()
val bottomSheet = projectFile(BOTTOM_SHEET_PATH).readText()
assertTrue(activity.contains("binding.ivShare.setOnClickListener { shareCurrentCreatorChannel() }"))
assertTrue(activity.contains("Intent(Intent.ACTION_SEND)"))
assertTrue(activity.contains("type = \"text/plain\""))
assertTrue(activity.contains("putExtra(Intent.EXTRA_TEXT"))
assertTrue(activity.contains("Intent.createChooser"))
assertTrue(activity.contains("R.string.screen_user_profile_share_channel"))
assertTrue(activity.contains("\"deep_link_value\" to \"channel\""))
assertTrue(activity.contains("\"deep_link_sub5\" to \"${'$'}creatorId\""))
assertTrue(activity.contains("binding.layoutFollowCapsule.visibility = View.VISIBLE"))
assertTrue(activity.contains("binding.ivMore.visibility = View.VISIBLE"))
assertTrue(bottomSheet.contains("onClickBlock?.invoke()"))
assertTrue(bottomSheet.contains("onClickUserReport?.invoke()"))
assertTrue(bottomSheet.contains("onClickProfileReport?.invoke()"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
private companion object {
const val ACTIVITY_PATH =
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
const val ACTIVITY_LAYOUT_PATH = "app/src/main/res/layout/activity_creator_channel.xml"
const val BOTTOM_SHEET_PATH =
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelMoreBottomSheet.kt"
const val BOTTOM_SHEET_LAYOUT_PATH = "app/src/main/res/layout/dialog_creator_channel_more.xml"
}
}
@@ -125,6 +125,20 @@ class CreatorChannelCommunityDetailContractTest {
assertEquals(CreatorChannelCommunityRepliesResponse(listOf(reply), 0, 20, false).replies.first(), reply)
}
@Test
fun `커뮤니티 상세 작성자 영역은 하나의 클릭으로 크리에이터 채널을 연다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel_community_detail.xml").readText()
val activity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/" +
"CreatorChannelCommunityDetailActivity.kt"
).readText()
assertTrue(layout.contains("@+id/layout_creator_channel_community_detail_creator"))
assertTrue(activity.contains("layoutCreatorChannelCommunityDetailCreator.setOnClickListener"))
assertTrue(activity.contains("CreatorChannelActivity.newIntent("))
assertTrue(activity.contains("post.creatorId"))
}
private fun projectFile(path: String): File {
var current = File(checkNotNull(System.getProperty("user.dir"))).absoluteFile
while (current.parentFile != null && !File(current, "settings.gradle").exists()) {
@@ -43,9 +43,10 @@ class CreatorChannelDonationViewModelTest {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.resetCanForTest()
SodaLiveApplicationHolder.init(context as Application)
SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.can = 200
SharedPreferenceManager.setCanForTest(200)
repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelDonationViewModel(repository)
}
@@ -54,6 +55,7 @@ class CreatorChannelDonationViewModelTest {
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetCanForTest()
SharedPreferenceManager.resetForTest()
}
@@ -127,7 +129,7 @@ class CreatorChannelDonationViewModelTest {
)
viewModel.loadDonations(100L, isOwner = false)
SharedPreferenceManager.can = 30
SharedPreferenceManager.setCanForTest(30)
viewModel.postChannelDonation(can = 250, isSecret = false, message = "응원")
verify(repository).postChannelDonation(100L, 250, false, "응원", "Bearer test-token")
@@ -87,6 +87,7 @@ class MainV2ActivitySourceTest {
val observerSource = source.substringFrom("private fun setupLiveEntryObservers()")
val updateSource = source.substringFrom("private fun updateRouteLoadingDialog()")
val setupViewSource = source.substringFrom("override fun setupView()")
val deferredRouteSource = source.substringFrom("private val deferredRouteRunnable = Runnable")
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.LoadingDialog"))
assertTrue(source.contains("private lateinit var routeLoadingDialog: LoadingDialog"))
@@ -104,7 +105,7 @@ class MainV2ActivitySourceTest {
)
)
assertBefore(onCreateSource, "isDeferredRouteLoading =", "handler.postDelayed")
assertTrue(onCreateSource.contains("isDeferredRouteLoading = false"))
assertTrue(deferredRouteSource.contains("isDeferredRouteLoading = false"))
assertTrue(updateSource.contains("if (isDeferredRouteLoading || isLiveEntryLoading)"))
assertTrue(updateSource.contains("routeLoadingDialog.show(screenWidth)"))
assertFalse(updateSource.contains("R.string.screen_live_loading"))
@@ -112,6 +113,61 @@ class MainV2ActivitySourceTest {
assertTrue(setupViewSource.contains("setupLiveEntryObservers()"))
}
@Test
fun `MainV2Activity는 foreground 라이브 딥링크만 로딩과 함께 지연한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val onNewIntentSource = source.substringFrom("override fun onNewIntent(intent: Intent)")
val liveRouteSource = source.substringFrom("private fun isLiveRoomDeepLink(intent: Intent): Boolean")
assertTrue(source.contains("import kr.co.vividnext.sodalive.live.room.LiveRoomActivity"))
assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "isLiveRoomDeepLink(intent)")
assertTrue(
onNewIntentSource.contains(
"LiveRoomActivity.isForeground && isLiveRoomDeepLink(intent)"
)
)
assertBefore(onNewIntentSource, "isDeferredRouteLoading = true", "updateRouteLoadingDialog()")
assertBefore(onNewIntentSource, "updateRouteLoadingDialog()", "handler.postDelayed(deferredRouteRunnable, 1000)")
assertBefore(onNewIntentSource, "handler.postDelayed(deferredRouteRunnable, 1000)", "return")
assertBefore(onNewIntentSource, "return", "executeDeeplink(intent)")
assertTrue(liveRouteSource.contains("val bundle = resolveDeepLinkBundle(intent) ?: return false"))
assertTrue(liveRouteSource.contains("if (isDmChatDeepLink(bundle)) return false"))
assertTrue(liveRouteSource.contains("if (roomId != null && roomId > 0) return true"))
assertTrue(liveRouteSource.contains("if (hasDirectNonLiveRoute(bundle)) return false"))
assertTrue(liveRouteSource.contains("bundle.getString(\"deep_link_value\")?.lowercase(Locale.ROOT) == \"live\""))
assertTrue(liveRouteSource.contains("deepLinkValueId != null && deepLinkValueId > 0"))
}
@Test
fun `MainV2Activity는 종료할 때 지연 딥링크와 로딩을 정리한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)")
val onDestroySource = source.substringFrom("override fun onDestroy()")
val runnableSource = source.substringFrom("private val deferredRouteRunnable = Runnable")
assertTrue(onCreateSource.contains("handler.postDelayed(deferredRouteRunnable, 1000)"))
assertTrue(runnableSource.contains("if (isFinishing || isDestroyed) return@Runnable"))
assertBefore(runnableSource, "executeDeeplink(intent)", "isDeferredRouteLoading = false")
assertBefore(runnableSource, "isDeferredRouteLoading = false", "updateRouteLoadingDialog()")
assertTrue(onDestroySource.contains("handler.removeCallbacks(deferredRouteRunnable)"))
assertTrue(onDestroySource.contains("isDeferredRouteLoading = false"))
assertTrue(onDestroySource.contains("routeLoadingDialog.dismiss()"))
}
@Test
fun `MainV2Activity의 비라이브 ID 판별은 실제 route와 같은 fallback 순서를 사용한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val routeSource = source.substringFrom("private fun hasDirectNonLiveRoute(bundle: Bundle): Boolean")
assertBefore(
routeSource,
"bundle.getString(stringKey)?.toLongOrNull()",
"?: bundle.getLong(longKey).takeIf { it > 0 }"
)
assertFalse(routeSource.contains("== true ||"))
}
@Test
fun `MainV2Activity는 Community post를 creator fallback보다 먼저 Action으로 라우팅한다`() {
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
@@ -30,8 +30,10 @@ class MainV2AudioNotificationRouteSourceTest {
assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)")
assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "isV2AccessAllowed(AccessRequirement.Login)")
assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
val deferredRouteSource = source.substringFrom("private val deferredRouteRunnable = Runnable")
assertTrue(onCreateSource.contains("handler.postDelayed(deferredRouteRunnable, 1000)"))
assertBefore(deferredRouteSource, "handleAudioNotificationRoute(intent)", "isV2AccessAllowed(AccessRequirement.Login)")
assertBefore(deferredRouteSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
}
@Test
@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.v2.main
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class MainV2DirectEntrySourceTest {
@Test
fun `라이브룸 딥링크 confirm은 MainV2Activity로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt"
).readText()
val receiverSource = source.substringAfter("private val deepLinkConfirmReceiver")
.substringBefore("// region lifecycle")
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.MainV2Activity"))
assertTrue(receiverSource.contains("MainV2Activity::class.java"))
assertTrue(receiverSource.contains("Intent.FLAG_ACTIVITY_CLEAR_TOP"))
assertTrue(receiverSource.contains("Intent.FLAG_ACTIVITY_SINGLE_TOP"))
assertTrue(receiverSource.contains("putExtra(Constants.EXTRA_DATA, bundle)"))
assertFalse(source.contains("import kr.co.vividnext.sodalive.main.MainActivity"))
assertFalse(receiverSource.contains("MainActivity::class.java"))
}
@Test
fun `예약 취소의 다른 수다 예약은 MainV2Activity로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/live/reservation/status/LiveReservationCancelActivity.kt"
).readText()
val clickSource = source.substringAfter("binding.tvOtherSudaReservation.setOnClickListener")
.substringBefore("binding.tvCheckCanStatus.setOnClickListener")
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.MainV2Activity"))
assertTrue(clickSource.contains("finishAffinity()"))
assertTrue(clickSource.contains("MainV2Activity::class.java"))
assertFalse(source.contains("import kr.co.vividnext.sodalive.main.MainActivity"))
assertFalse(clickSource.contains("MainActivity::class.java"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}
@@ -68,10 +68,8 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)"))
assertTrue(source.contains("tvTitleBarTitle.setText(R.string.tab_chat)"))
assertTrue(source.contains("ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)"))
assertTrue(source.contains("llTitleBarActions.addView"))
assertTrue(source.contains("setImageResource(R.drawable.ic_bar_search)"))
assertTrue(source.contains("LinearLayout.LayoutParams"))
assertTrue(source.contains("marginStart = resources.getDimensionPixelSize(R.dimen.spacing_14)"))
assertFalse(source.contains("setImageResource(R.drawable.ic_bar_search)"))
assertFalse(source.contains("SearchActivity::class.java"))
assertTrue(source.contains("setMenus("))
assertTrue(source.contains("R.string.screen_chat_filter_all"))
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
@@ -110,6 +108,28 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false"))
}
@Test
fun `채팅방에서 복귀하면 현재 filter 첫 페이지로 목록을 갱신한다`() {
val source = chatMainFragmentSource()
val onResume = source.substringBetween(
"override fun onResume()",
"private fun setupTitleBar()"
)
assertTrue(source.contains("private var shouldRefreshChatRoomsOnResume = false"))
val onViewCreated = source.substringBetween(
"override fun onViewCreated",
"viewModel.loadFirstPage(initialFilter)"
)
assertTrue(onViewCreated.contains("shouldRefreshChatRoomsOnResume = false"))
assertTrue(onResume.contains("super.onResume()"))
assertTrue(onResume.contains("if (!shouldRefreshChatRoomsOnResume)"))
assertTrue(onResume.contains("shouldRefreshChatRoomsOnResume = true"))
assertTrue(onResume.contains("cancelChatPullRefresh()"))
assertTrue(onResume.contains("bindChatRooms(emptyList(), showEmpty = false)"))
assertTrue(onResume.contains("viewModel.loadFirstPage(selectedFilter)"))
}
@Test
fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() {
val source = chatMainFragmentSource()
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.main.chat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
@@ -7,16 +8,17 @@ import java.io.File
class ChatMainTitleBarActionSourceTest {
@Test
fun `대화 title bar 우측 아이콘은 기존 목적지로 이동한다`() {
fun `대화 title bar 우측 아이콘은 캔 충전으로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
).readText()
assertTrue(source.contains("ivTitleBarMenu.setOnClickListener"))
assertTrue(source.contains("searchAction.setOnClickListener"))
assertTrue(source.contains("ensureV2Access"))
assertTrue(source.contains("CanChargeActivity::class.java"))
assertTrue(source.contains("SearchActivity::class.java"))
assertTrue(source.contains("openWithLoginGuard(CanChargeActivity::class.java)"))
assertFalse(source.contains("searchAction.setOnClickListener"))
assertFalse(source.contains("SearchActivity::class.java"))
}
private fun projectFile(relativePath: String): File {
@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm
import com.google.gson.Gson
import com.google.gson.JsonParser
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
@@ -27,6 +29,7 @@ import org.junit.Test
class DmChatRepositoryTest {
private val gson = Gson()
private val api = FakeDmChatApi()
private val socketFactory = FakeWebSocketFactory()
private val socketClient = DmChatSocketClient(
@@ -45,6 +48,32 @@ class DmChatRepositoryTest {
assertEquals(CreateDmChatRoomRequest(creatorId = 11L), api.lastCreateRequest)
}
@Test
fun `리스너 DM 생성 request는 recipientId만 직렬화한다`() {
val json = gson.toJson(CreateDmChatRoomRequest(recipientId = 22L))
val jsonObject = JsonParser.parseString(json).asJsonObject
assertEquals(22L, jsonObject.get("recipientId").asLong)
assertFalse(jsonObject.has("creatorId"))
}
@Test
fun `createOrGetRoomByRecipientId는 bearer header와 recipientId를 API에 위임한다`() {
repository.createOrGetRoomByRecipientId(token = "test-token", recipientId = 22L).blockingGet()
assertEquals("Bearer test-token", api.lastAuthHeader)
assertEquals(CreateDmChatRoomRequest(recipientId = 22L), api.lastCreateRequest)
}
@Test
fun `크리에이터 채널 DM 생성 request는 creatorId만 직렬화한다`() {
val json = gson.toJson(CreateDmChatRoomRequest(creatorId = 11L))
val jsonObject = JsonParser.parseString(json).asJsonObject
assertEquals(11L, jsonObject.get("creatorId").asLong)
assertFalse(jsonObject.has("recipientId"))
}
@Test
fun `openRoom은 기본 limit 20과 bearer header를 API에 위임한다`() {
repository.openRoom(token = "test-token", roomId = 12L).blockingGet()
@@ -0,0 +1,161 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
class DmRecipientPickerSourceTest {
@Test
fun `대화 탭은 크리에이터에게만 수신자 선택 진입을 연결한다`() {
val source = source("main/chat/ChatMainFragment.kt")
assertTrue(source.contains("SharedPreferenceManager.role == MemberRole.CREATOR.name"))
assertTrue(source.contains("binding.btnNewDm.isVisible = isCreator"))
assertTrue(source.contains("if (isCreator)"))
assertTrue(source.contains("openWithLoginGuard(DmRecipientPickerActivity::class.java)"))
}
@Test
fun `수신자 화면은 팔로워와 검색 및 빈 결과 상태를 연결한다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertTrue(source.contains("viewModel.enter()"))
assertTrue(source.contains("viewModel.onSearchQueryChanged"))
assertTrue(source.contains("DmRecipientPickerMode.FOLLOWERS"))
assertTrue(source.contains("state.searchResultCount"))
assertTrue(source.contains("state.isSearchEmpty"))
assertTrue(source.contains("adapter.submitItems(state.recipients)"))
val factory = source("main/chat/recipient/DmRecipientPickerFactory.kt")
assertTrue(factory.contains("getFollowerList("))
assertTrue(factory.contains("SharedPreferenceManager.userId"))
assertTrue(factory.contains("searchUser("))
assertTrue(factory.contains("observeOn(AndroidSchedulers.mainThread())"))
}
@Test
fun `팔로워 라벨은 팔로워 모드이고 표시할 수신자가 있을 때만 보인다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertTrue(
source.contains(
"binding.tvFollowers.isVisible = " +
"state.mode == DmRecipientPickerMode.FOLLOWERS && state.recipients.isNotEmpty()"
)
)
}
@Test
fun `수신자 선택은 공통 확인 팝업에서만 요청하고 취소는 부작용이 없다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
val confirmation = source.substringAfter("private fun confirmRecipient(").substringBefore("private fun openRoom(")
assertTrue(confirmation.contains("V2ModalDialog("))
assertTrue(confirmation.contains("R.string.dm_recipient_confirm_title"))
assertTrue(confirmation.contains("getString(R.string.dm_recipient_confirm_message, recipient.nickname)"))
assertTrue(confirmation.contains("R.string.cancel"))
assertTrue(confirmation.contains("R.string.dm_recipient_send"))
assertTrue(confirmation.contains("confirmButtonClick = { openRoom(recipient.id) }"))
assertFalse(confirmation.contains("cancelButtonClick"))
assertFalse(confirmation.contains("createOrGetRoom"))
}
@Test
fun `보내기는 recipient 경로로 생성한 방 아이디로 기존 DM 화면을 연다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertTrue(source.contains("createOrGetRoomByRecipientId(SharedPreferenceManager.token, recipientId)"))
assertTrue(source.contains("response.success && data != null"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(this, data.roomId)"))
assertFalse(source.contains("newIntentByCreatorId"))
assertTrue(
file("app/src/main/AndroidManifest.xml").readText().contains(
"kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity"
)
)
}
@Test
fun `수신자 화면 layout은 Figma 검색 toolbar와 검색결과 count를 사용한다`() {
val layout = file("app/src/main/res/layout/activity_dm_recipient_picker.xml").readText()
assertFalse(layout.contains("layout=\"@layout/detail_toolbar\""))
assertTrue(layout.contains("android:id=\"@+id/btn_back\""))
assertTrue(layout.contains("android:id=\"@+id/search_container\""))
assertTrue(layout.contains("android:id=\"@+id/iv_search\""))
assertTrue(layout.contains("android:id=\"@+id/et_search_nickname\""))
assertTrue(layout.contains("android:id=\"@+id/tv_search_result_count\""))
assertTrue(layout.contains("@string/dm_recipient_search_result_count"))
}
@Test
fun `검색 empty 문구는 목록 콘텐츠 영역에서 전환된다`() {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(file("app/src/main/res/layout/activity_dm_recipient_picker.xml"))
val content = requireNotNull(document.findById("recipient_content_container"))
val empty = requireNotNull(document.findById("tv_search_empty"))
val list = requireNotNull(document.findById("rv_recipients"))
assertSame(content, empty.parentNode)
assertSame(content, list.parentNode)
}
@Test
fun `팔로워와 검색결과 라벨은 같은 문구 크기를 쓰고 검색결과 숫자만 gray500으로 보인다`() {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(file("app/src/main/res/layout/activity_dm_recipient_picker.xml"))
val followers = requireNotNull(document.findById("tv_followers"))
val searchResultCount = requireNotNull(document.findById("tv_search_result_count"))
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertEquals(followers.attribute("style"), searchResultCount.attribute("style"))
assertTrue(
source.contains(
"ForegroundColorSpan(ContextCompat.getColor(this@DmRecipientPickerActivity, R.color.gray_500))"
)
)
assertTrue(source.contains("state.searchResultCount.toString()"))
}
@Test
fun `새 수신자 문구는 지원 언어 전체에 정의된다`() {
val names = listOf(
"dm_recipient_search_hint",
"dm_recipient_followers",
"dm_recipient_search_result_count",
"dm_recipient_search_empty",
"dm_recipient_confirm_title",
"dm_recipient_confirm_message",
"dm_recipient_send"
)
for (directory in listOf("values", "values-en", "values-ja")) {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(file("app/src/main/res/$directory/strings.xml"))
val strings = document.getElementsByTagName("string")
val available = (0 until strings.length).map {
strings.item(it).attributes.getNamedItem("name").nodeValue
}
names.forEach { assertTrue("$directory/$it", it in available) }
}
}
private fun source(path: String): String {
val file = file("app/src/main/java/kr/co/vividnext/sodalive/v2/$path")
assertTrue("수신자 연결 소스가 필요함: $path", file.exists())
return file.readText()
}
private fun file(path: String): File =
if (File("app").isDirectory) File(path) else File("../$path")
private fun org.w3c.dom.Document.findById(id: String): org.w3c.dom.Node? {
val nodes = getElementsByTagName("*")
return (0 until nodes.length).map { nodes.item(it) }
.firstOrNull { node ->
val value = node.attributes?.getNamedItem("android:id")?.nodeValue
value == "@+id/$id" || value == "@id/$id"
}
}
private fun org.w3c.dom.Node.attribute(name: String): String =
requireNotNull(attributes?.getNamedItem(name)?.nodeValue) { "missing $name" }
}
@@ -0,0 +1,215 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import android.app.Application
import androidx.lifecycle.LiveData
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.TestScheduler
import io.reactivex.rxjava3.subjects.SingleSubject
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class DmRecipientPickerViewModelTest {
private lateinit var debounceScheduler: TestScheduler
private lateinit var source: FakeRecipientSource
private lateinit var viewModel: DmRecipientPickerViewModel
@Before
fun setUp() {
debounceScheduler = TestScheduler()
source = FakeRecipientSource()
viewModel = DmRecipientPickerViewModel(
loadFollowers = source::loadFollowers,
searchUsers = source::searchUsers,
debounceScheduler = debounceScheduler
)
}
@Test
fun `진입 시 팔로우 목록을 1회 로드한다`() {
source.followers = listOf(recipient(id = 1L, nickname = "팬A"))
viewModel.enter()
assertEquals(1, source.followerCalls)
assertEquals(emptyList<String>(), source.searchQueries)
assertEquals(
DmRecipientPickerUiState(
mode = DmRecipientPickerMode.FOLLOWERS,
recipients = source.followers
),
viewModel.recipientStateLiveData.requireValue()
)
}
@Test
fun `검색어가 0에서 1자이면 검색하지 않고 팔로우 목록으로 복귀한다`() {
source.followers = listOf(recipient(id = 1L, nickname = "팬A"))
viewModel.enter()
viewModel.onSearchQueryChanged("가")
assertEquals(2, source.followerCalls)
assertEquals(emptyList<String>(), source.searchQueries)
assertEquals(
DmRecipientPickerUiState(
mode = DmRecipientPickerMode.FOLLOWERS,
recipients = source.followers
),
viewModel.recipientStateLiveData.requireValue()
)
}
@Test
fun `검색어 2자 이상은 500ms 후 마지막 검색어만 요청한다`() {
source.searchResults = listOf(recipient(id = 2L, nickname = "팬B"))
viewModel.onSearchQueryChanged("가나")
debounceScheduler.advanceTimeBy(499L, TimeUnit.MILLISECONDS)
viewModel.onSearchQueryChanged("가나다")
debounceScheduler.advanceTimeBy(499L, TimeUnit.MILLISECONDS)
assertEquals(emptyList<String>(), source.searchQueries)
debounceScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
assertEquals(listOf("가나다"), source.searchQueries)
assertEquals(
DmRecipientPickerUiState(
mode = DmRecipientPickerMode.SEARCH,
recipients = source.searchResults,
searchResultCount = source.searchResults.size
),
viewModel.recipientStateLiveData.requireValue()
)
}
@Test
fun `검색 결과가 없으면 empty 상태를 표시한다`() {
source.searchResults = emptyList()
viewModel.onSearchQueryChanged("없는팬")
debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS)
assertEquals(
DmRecipientPickerUiState(
mode = DmRecipientPickerMode.SEARCH,
recipients = emptyList(),
searchResultCount = 0,
isSearchEmpty = true
),
viewModel.recipientStateLiveData.requireValue()
)
}
@Test
fun `검색 결과 개수는 검색 결과가 0개여도 상태에 포함된다`() {
source.searchResults = emptyList()
viewModel.onSearchQueryChanged("홍길동")
debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS)
assertEquals(0, viewModel.recipientStateLiveData.requireValue()?.searchResultCount)
}
@Test
fun `팔로워 요청 실패는 오류 상태로 전달한다`() {
val model = DmRecipientPickerViewModel(
loadFollowers = { Single.error(IllegalStateException()) },
searchUsers = source::searchUsers,
debounceScheduler = debounceScheduler
)
model.enter()
assertEquals(true, model.recipientStateLiveData.value?.isError)
}
@Test
fun `검색 요청 실패는 빈 결과 대신 오류 상태로 전달한다`() {
val model = DmRecipientPickerViewModel(
loadFollowers = source::loadFollowers,
searchUsers = { Single.error(IllegalStateException()) },
debounceScheduler = debounceScheduler
)
model.onSearchQueryChanged("검색")
debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS)
assertEquals(true, model.recipientStateLiveData.value?.isError)
assertEquals(false, model.recipientStateLiveData.value?.isSearchEmpty)
}
@Test
fun `팔로워로 복귀하면 이전 검색 응답은 목록을 덮어쓰지 않는다`() {
val pendingSearch = SingleSubject.create<List<DmRecipientUiModel>>()
val model = DmRecipientPickerViewModel(
source::loadFollowers,
{ pendingSearch },
debounceScheduler
)
model.onSearchQueryChanged("검색")
debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS)
model.onSearchQueryChanged("")
pendingSearch.onSuccess(listOf(recipient(2L, "지난검색")))
assertEquals(DmRecipientPickerMode.FOLLOWERS, model.recipientStateLiveData.value?.mode)
assertEquals(source.followers, model.recipientStateLiveData.value?.recipients)
}
@Test
fun `검색으로 전환하면 늦은 팔로워 응답은 검색 결과를 덮어쓰지 않는다`() {
val pendingFollowers = SingleSubject.create<List<DmRecipientUiModel>>()
val model = DmRecipientPickerViewModel(
{ pendingFollowers },
source::searchUsers,
debounceScheduler
)
model.enter()
model.onSearchQueryChanged("검색")
debounceScheduler.advanceTimeBy(500L, TimeUnit.MILLISECONDS)
pendingFollowers.onSuccess(listOf(recipient(1L, "팔로워")))
assertEquals(DmRecipientPickerMode.SEARCH, model.recipientStateLiveData.value?.mode)
assertEquals(source.searchResults, model.recipientStateLiveData.value?.recipients)
}
private class FakeRecipientSource {
var followers = emptyList<DmRecipientUiModel>()
var searchResults = emptyList<DmRecipientUiModel>()
var followerCalls = 0
val searchQueries = mutableListOf<String>()
fun loadFollowers(): Single<List<DmRecipientUiModel>> {
followerCalls += 1
return Single.just(followers)
}
fun searchUsers(query: String): Single<List<DmRecipientUiModel>> {
searchQueries += query
return Single.just(searchResults)
}
}
}
private fun recipient(
id: Long,
nickname: String
) = DmRecipientUiModel(
id = id,
nickname = nickname,
profileImageUrl = "https://example.com/$id.png"
)
private fun <T> LiveData<T>.requireValue(): T? {
return value
}
@@ -0,0 +1,283 @@
# 크리에이터 채널 공유 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | `2026-09-14` |
| 요구사항 기준 | `docs/20260914_크리에이터_채널_공유/prd.md` |
| API 기준 | 없음 |
| 현재 Phase | Phase 1 |
| 현재 활성 Goal | 없음 |
## 목표
크리에이터 채널 화면에서 내 채널은 우측 상단 공유 버튼으로, 다른 사람 채널은 더보기 BottomSheet의 `채널 공유` 항목으로 같은 채널 공유 chooser를 실행한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `5/5` | 없음 | 실제 앱 수동 테스트는 사용자 확인 대기 |
## 범위
### 포함
- `CreatorChannelActivity` title bar에 owner 전용 공유 버튼을 추가한다.
- `CreatorChannelMoreBottomSheet`에 non-owner 전용 `채널 공유` 항목 callback을 추가한다.
- 기존 `UserProfileActivity` 887~902줄과 같은 `Intent.ACTION_SEND` chooser 흐름을 사용한다.
- 기존 `UserProfileViewModel.shareChannel()`과 같은 OneLink params/message 형식을 사용한다.
### 제외
- 서버 API, analytics, 신규 dependency 추가.
- 레거시 `UserProfileActivity`, `UserProfileViewModel` 수정.
- `app/src/androidTest`, 기기·에뮬레이터 조작, 스크린샷 QA.
## 기술적 제약
- 기술 스택: Android Kotlin, XML layout, ViewBinding, Material BottomSheet.
- 아키텍처: `CreatorChannelActivity`가 header owner 상태를 기준으로 title action을 bind하고 공유 chooser를 실행한다.
- 데이터·보안: 공유 텍스트에는 공개 닉네임과 OneLink URL만 포함하며 token/BuildConfig 값은 노출하지 않는다.
- 의존성: 신규 dependency를 추가하지 않는다.
- 테스트 범위: `app/src/test` source/local unit test만 계획한다.
- 구현: 각 구현 Task는 RED → GREEN → REFACTOR 순서로 진행한다.
## 구현 파일 맵
- Modify: `app/src/main/res/layout/activity_creator_channel.xml`
- `title_action_container` 안에 `iv_share` ImageView를 추가하고 `@drawable/ic_audio_content_share`를 지정한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- `iv_share` click listener, owner/non-owner visibility, BottomSheet 공유 callback, chooser 실행 함수를 추가한다.
- Modify: `app/src/main/res/layout/dialog_creator_channel_more.xml`
- 기존 메뉴 항목과 같은 스타일로 `tv_channel_share`를 추가한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelMoreBottomSheet.kt`
- `onClickChannelShare` callback을 추가하고 항목 클릭 시 dismiss 후 호출한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelShareSourceTest.kt`
- layout/source 계약을 검증한다.
## Phase 1
**Phase 결과:** 크리에이터 채널의 owner/non-owner 공유 진입점과 공유 chooser 실행 경로가 구현된다.
**선행조건:** `docs/20260914_크리에이터_채널_공유/prd.md` 확정.
**Phase 완료 조건:** `P1-T1`~`P1-T3`, `P1-R1`, `P1-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 1.1 owner title bar 공유 버튼 추가
**Goal 실행 `P1-T1`:** 내 채널 우측 상단에 `ic_audio_content_share` 공유 버튼을 표시한다.
- **시작 조건:** `SHARE-001`, `SHARE-002` 확정.
- **완료 증거:** source test PASS, `activity_creator_channel.xml`에 공유 버튼 추가, `bindTitleBar()` owner 분기 갱신.
- **범위 밖:** BottomSheet 공유 항목과 chooser 실행 구현.
**Files:**
- Modify: `app/src/main/res/layout/activity_creator_channel.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelShareSourceTest.kt`
**Interfaces:**
- Consumes: `CreatorChannelHeaderUiModel.isOwner`
- Produces: `iv_share` owner 전용 title action
- [x] **RED:** `CreatorChannelShareSourceTest`에 `activity_creator_channel.xml`이 `@+id/iv_share`와 `@drawable/ic_audio_content_share`를 포함해야 한다는 source test를 추가한다. `CreatorChannelActivity.kt`가 owner 분기에서 `binding.ivShare.visibility = View.VISIBLE`를 포함해야 한다는 source test를 추가한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"`를 실행해 `iv_share` 또는 owner visibility 계약 미구현 실패를 확인한다.
- [x] **GREEN:** `activity_creator_channel.xml`의 `title_action_container`에 `ImageView` `@+id/iv_share`를 추가하고 `android:src="@drawable/ic_audio_content_share"`, `android:visibility="gone"`을 지정한다. `CreatorChannelActivity.bindTitleBar()` owner 분기에서 follow, bell, more를 숨기고 share를 보이게 하며, non-owner 분기에서는 share를 숨긴다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 PASS를 확인한다.
- [x] **REFACTOR:** 새 동작을 바꾸지 않는 범위에서 이번 Task가 만든 중복만 정리하고 focused test 결과를 Progress에 기록한다.
#### Task 1.2 non-owner BottomSheet 공유 항목 추가
**Goal 실행 `P1-T2`:** 다른 사람 채널 더보기 BottomSheet에 `채널 공유` 항목을 추가한다.
- **시작 조건:** `P1-T1` 완료, `SHARE-003` 확정.
- **완료 증거:** source test PASS, 기존 block/report/profile report callback 유지.
- **범위 밖:** 실제 chooser payload 생성.
**Files:**
- Modify: `app/src/main/res/layout/dialog_creator_channel_more.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelMoreBottomSheet.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelShareSourceTest.kt`
**Interfaces:**
- Consumes: `CreatorChannelMoreBottomSheet.newInstance()`
- Produces: `CreatorChannelMoreBottomSheet.onClickChannelShare`
- [x] **RED:** source test에 `dialog_creator_channel_more.xml`의 `@+id/tv_channel_share`와 `@string/screen_user_profile_share_channel` 존재를 검증한다. `CreatorChannelMoreBottomSheet.kt`가 `onClickChannelShare` callback과 `tvChannelShare.setOnClickListener`를 포함해야 한다는 source test를 추가한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"`를 실행해 BottomSheet 공유 항목 미구현 실패를 확인한다.
- [x] **GREEN:** `dialog_creator_channel_more.xml` 상단 메뉴 영역에 기존 TextView와 같은 style/padding/textColor/textSize로 `tv_channel_share`를 추가한다. `CreatorChannelMoreBottomSheet`에 `var onClickChannelShare: (() -> Unit)? = null`을 추가하고 클릭 시 `dismiss()` 후 callback을 호출한다. `CreatorChannelActivity.onMoreClicked()`에서 `onClickChannelShare = { shareCurrentCreatorChannel() }`를 연결한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 PASS를 확인한다.
- [x] **REFACTOR:** 기존 block/report/profile report callback이 삭제되지 않았는지 source test와 diff로 확인하고 Progress에 기록한다.
#### Task 1.3 공유 메시지와 chooser 실행 연결
**Goal 실행 `P1-T3`:** 두 공유 진입점이 기존 프로필 공유와 같은 텍스트 payload로 Android chooser를 실행한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료, `SHARE-004` 확정.
- **완료 증거:** source test PASS, 기존 프로필 공유와 같은 ACTION_SEND/EXTRA_TEXT/chooser title 확인.
- **범위 밖:** 공유 메시지 문구 변경, 신규 API, analytics.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelShareSourceTest.kt`
**Interfaces:**
- Consumes: `currentHeader.creatorId`, `currentHeader.nickname`, `Utils.createOneLinkUrl`
- Produces: `shareCurrentCreatorChannel()`
- [x] **RED:** source test에 `CreatorChannelActivity.kt`가 `Intent(Intent.ACTION_SEND)`, `intent.type = "text/plain"`, `Intent.EXTRA_TEXT`, `Intent.createChooser`, `R.string.screen_user_profile_share_channel`, `deep_link_value` = `channel`, `deep_link_sub5`를 포함해야 한다는 계약을 추가한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"`를 실행해 공유 payload 미구현 실패를 확인한다.
- [x] **GREEN:** `CreatorChannelActivity`에 `shareCurrentCreatorChannel()` private 함수를 추가한다. `currentHeader`가 없거나 `creatorId <= 0L`이면 return한다. params는 `af_dp=voiceon://`, `deep_link_value=channel`, `deep_link_sub5=$creatorId`를 사용한다. 메시지는 `소다라이브 ${header.nickname}님의 채널입니다.\n$shareUrl`로 만들고 기존 Activity 공유 코드와 같은 chooser를 실행한다. `setupClickListeners()`에서 `binding.ivShare.setOnClickListener { shareCurrentCreatorChannel() }`를 연결한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 PASS를 확인한다.
- [x] **REFACTOR:** `UserProfileActivity`, `UserProfileViewModel`이 수정되지 않았는지 `git diff -- app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileActivity.kt app/src/main/java/kr/co/vividnext/sodalive/explorer/profile/UserProfileViewModel.kt`로 확인하고 Progress에 기록한다.
### 완료 조건
- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] `SHARE-001~004`, `REG-001~002`가 구현·검증 기록으로 추적된다.
- [x] 레거시 프로필 파일 수정이 없다.
### 검증 방법
#### Task 1.4 후원 테스트 can 격리 회귀 수정
**Goal 실행 `P1-R1`:** `CreatorChannelHomeViewModelTest`와 `CreatorChannelDonationViewModelTest`가 공유 `SharedPreferenceManager.can` 상태에 영향받지 않도록 테스트 격리를 보강한다.
- **시작 조건:** `ISSUE-001` 재현 및 root cause 확인.
- **완료 증거:** `SharedPreferenceManagerTest` PASS, `creator.channel.*` 패키지 테스트 PASS.
- **범위 밖:** 후원 ViewModel production 동작 변경, 전체 DataStore reset, 레거시 파일 수정.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationViewModelTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/common/SharedPreferenceManagerTest.kt`
**Interfaces:**
- Consumes: `SharedPreferenceManager.can`, `DataStore.edit`
- Produces: `resetCanForTest()`, `setCanForTest(value)` 테스트 전용 동기 helper
- [x] **RED:** `SharedPreferenceManagerTest`에 저장된 `can` 값을 테스트 전용 reset으로 초기화해야 한다는 회귀 테스트를 추가했다.
- [x] **RED 확인:** production helper 미구현 상태에서 focused test가 컴파일 실패하는 것을 확인하려 했으나 Gradle 초기 compile이 timeout됐다. 이후 helper 구현 전 계약 실패 조건은 `resetForTest(clearPersisted = true)` 미존재로 확인했다.
- [x] **GREEN:** 전체 DataStore를 지우지 않고 `PREF_CAN` 키만 동기적으로 제거·설정하는 `resetCanForTest()`, `setCanForTest(value)`를 추가했다. `CreatorChannelHomeViewModelTest`, `CreatorChannelDonationViewModelTest`의 `can` setup/teardown만 해당 helper로 교체했다.
- [x] **GREEN 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.SharedPreferenceManagerTest" --rerun-tasks` PASS, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"` PASS를 확인했다.
- [x] **REFACTOR:** 전체 DataStore clear 방식은 병렬 테스트의 `token` 상태까지 지워 NPE를 만들었으므로 제거하고, `can` 키 한정 동기 helper로 범위를 줄였다.
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 크리에이터 채널 공유 진입점과 기존 더보기 동작 보존을 최종 판정한다.
- **시작 조건:** Phase 1의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 명령과 Expected 통과, Progress에 실제 결과 누적.
- **범위 밖:** 실패와 무관한 UI 리팩터링, 신규 dependency, 계측 테스트 추가.
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"
./gradlew :app:mergeDebugResources
git diff --check
```
**Expected:** focused source test PASS, resource merge PASS, whitespace error 0건.
수동 검증:
- [ ] 내 채널 진입 시 우측 상단 `ic_audio_content_share` 버튼이 표시되고 더보기는 숨겨진다. — 사용자 수동 확인 대기
- [ ] 내 채널 공유 버튼 탭 시 Android 공유 chooser 제목이 `채널 공유`로 표시된다. — 사용자 수동 확인 대기
- [ ] 다른 사람 채널 진입 시 기존 follow, bell, more가 표시되고 공유 버튼은 숨겨진다. — 사용자 수동 확인 대기
- [ ] 다른 사람 채널 더보기 BottomSheet에서 `채널 공유` 선택 시 같은 Android 공유 chooser가 표시된다. — 사용자 수동 확인 대기
- [ ] 기존 차단, 유저 신고, 프로필 신고 항목이 계속 동작한다. — 사용자 수동 확인 대기
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | PRD 확정 | 아니요 | title bar layout/source 계약 재확인 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | BottomSheet binding 이름 재확인 |
| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 기존 프로필 공유 코드 재대조 |
| 4 | `P1-R1` | `ISSUE-001` 재현 | 아니요 | `SharedPreferenceManager.can` 테스트 격리 root cause 재확인 |
| 5 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
```text
P1-T1 → P1-T2 → P1-T3 → P1-R1 → P1-GATE
```
## 변경 금지 항목
- `UserProfileActivity`, `UserProfileViewModel`을 수정하지 않는다.
- 공유 메시지, OneLink key, chooser title을 임의 변경하지 않는다.
- 신규 dependency, 신규 서버 API, analytics event를 추가하지 않는다.
- test를 삭제·skip·완화하거나 타입 오류를 우회하지 않는다.
- 요청 범위 밖의 layout 리팩터링을 하지 않는다.
## 의사결정 및 중단 규칙
- `currentHeader`가 없는 상태에서 공유 요청을 처리할 수 없으면 no-op으로 둔다.
- PRD와 구현 계획이 충돌하면 구현 전에 문서를 먼저 정정한다.
- 공유 링크 정책 변경 요청이 나오면 PRD Decision Log를 정정하고 `P1-T3` 범위를 갱신한다.
- 완료 증거와 Progress 기록까지 충족한 뒤에만 goal을 complete로 갱신한다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### 문서 작성 — 2026-09-14
- 상태: 완료
- 무엇을: `prd.md`, `plan-task.md` 신규 작성.
- 왜: 사용자 요청이 문서 생성까지만 허용하고 코드 구현을 금지했기 때문.
- 어떻게:
- `UserProfileActivity.kt:887~902` — 기존 Android chooser 공유 흐름 확인.
- `UserProfileViewModel.kt:351~362` — 기존 채널 OneLink params와 메시지 형식 확인.
- `CreatorChannelActivity.kt:230~441` — title action, owner 분기, 더보기 BottomSheet 진입 확인.
- `activity_creator_channel.xml:179~238` — title action container와 `iv_more` 구조 확인.
- `dialog_creator_channel_more.xml:19~47`, `CreatorChannelMoreBottomSheet.kt:12~43` — 기존 BottomSheet 메뉴/callback 구조 확인.
- 남은 항목: 코드 구현 전체.
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`부터 진행.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| `2026-09-14` | `DEC-001` | 확정 | 내 채널은 title bar 직접 공유 버튼, 타인 채널은 더보기 BottomSheet 공유 항목으로 구현한다. | 사용자 요청 | `P1-T1`, `P1-T2` |
| `2026-09-14` | `DEC-002` | 확정 | 기존 프로필 공유의 chooser와 OneLink 형식을 크리에이터 채널 v2 안에서 동일하게 사용한다. | `UserProfileActivity.kt:887~902`, `UserProfileViewModel.kt:351~362` 확인 | `P1-T3` |
| `2026-09-14` | `DEC-003` | 확정 | 시각 QA와 스크린샷 검증은 수행하지 않고 실제 앱 수동 테스트로 확인한다. | 사용자 지시 | `P1-GATE` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| 없음 | Low | 후보 없음 | 문서 작성 범위에서 차단 이슈 없음 | 없음 | 없음 |
| `ISSUE-001` | Low | 해결 | `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`가 `CreatorChannelHomeViewModelTest`의 `SharedPreferenceManager.can` assertion에서 1건 실패했다. 원인은 `SharedPreferenceManager.can` setter가 비동기 DataStore write를 수행하고, 테스트 간 persisted `PREF_CAN` 값이 재초기화 시점에 다시 읽힐 수 있는 격리성 문제였다. | `P1-R1`, `P1-GATE` | `PREF_CAN`만 동기 초기화·설정하는 테스트 전용 helper를 추가하고 후원 관련 테스트 setup/teardown에 적용했다. |
## 최종 보고 형식
```markdown
구현 결과: 완료한 Phase와 사용자 흐름을 적는다.
- 변경: 주요 파일과 동작을 적는다.
- 결정: 중요한 Decision Log ID와 내용을 적는다.
- 검증:
- `실행 명령` — 성공/실패와 핵심 수치를 적는다.
- `수동 검증` — 성공/실패/불가 사유를 적는다.
- 남은 항목: 외부 의존, 후속 범위 또는 없음을 적는다.
- 문서: 갱신한 PRD/API Contract/plan/review 링크를 적는다.
```
## Verification Log
- 2026-09-14: 문서 작성만 수행했다. 코드 구현, Gradle 빌드, 기능 테스트는 실행하지 않았다.
- 2026-09-14: 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest" --rerun-tasks` PASS, `./gradlew :app:mergeDebugResources` PASS, `./gradlew :app:assembleDebug` PASS, `git diff --check` PASS를 확인했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`는 `CreatorChannelHomeViewModelTest`의 후원 can assertion 1건으로 실패했으나, 실패 후보 테스트 2개는 각각 단독 실행 PASS했다. 사용자가 시각 QA 대신 실제 앱 수동 테스트를 선택했으므로 수동 검증 체크박스는 사용자 확인 대기로 남겼다.
- 2026-09-15: `ISSUE-001` 후속으로 `SharedPreferenceManager.can` 테스트 격리 문제를 수정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.SharedPreferenceManagerTest" --rerun-tasks` PASS, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"` PASS, `git diff --check` PASS를 확인했다. 공유 기능 focused test `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"`도 PASS 상태를 재확인했다.
@@ -0,0 +1,178 @@
# 크리에이터 채널 공유 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | `2026-09-14` |
| 최종 수정일 | `2026-09-15` |
| 대상 제품 | 크리에이터 채널 공유 |
| 작성자·결정권자 | 사용자 요청 기준 |
| 관련 API Contract | 없음 |
| 관련 구현 계획 | `docs/20260914_크리에이터_채널_공유/plan-task.md` |
| 관련 review | `docs/20260914_크리에이터_채널_공유/reviews/phase1-creator-channel-share-review.md` |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | Non-Goals와 Decision Log에 기록 |
## 1. Overview
크리에이터 채널 화면에서 사용자가 현재 보고 있는 채널을 Android 공유 시트로 공유할 수 있게 한다. 내 채널은 우측 상단에 공유 버튼을 바로 노출하고, 다른 사람 채널은 우측 상단 더보기 BottomSheet 안에 `채널 공유` 항목을 추가한다.
## 2. Problem Statement
현재 크리에이터 채널 화면에는 채널 공유 진입점이 없다. 사용자는 크리에이터 채널을 외부 앱으로 공유하려면 기존 프로필 화면 같은 다른 경로를 찾아야 한다. 이 기능이 완료되면 크리에이터 채널 화면에서 소유자와 방문자 모두 같은 채널 공유 메시지를 Android 공유 시트로 보낼 수 있다.
## 3. Goals
### 3.1 제품 목표
- 내 채널 사용자는 크리에이터 채널 우측 상단 공유 버튼을 눌러 즉시 채널을 공유한다.
- 다른 사람 채널 사용자는 우측 상단 더보기 버튼을 누른 뒤 `채널 공유`를 선택해 채널을 공유한다.
- 공유 메시지와 링크 생성 방식은 `UserProfileActivity`의 기존 채널 공유 흐름과 동일하게 유지한다.
### 3.2 UX 목표
- 내 채널에서는 기존에 숨겨지던 우측 상단 영역에 공유 버튼만 표시한다.
- 다른 사람 채널에서는 기존 follow, 알림, 더보기 동작을 유지하고 더보기 BottomSheet에 공유 항목을 추가한다.
- 우측 상단 공유 버튼 아이콘은 `@drawable/ic_audio_content_share`를 사용한다.
## 4. Non-Goals
- 신규 공유 API, 서버 계약, analytics event를 추가하지 않는다.
- `UserProfileActivity`, `UserProfileViewModel` 같은 레거시 파일은 수정하지 않는다.
- 공유 메시지 문구, OneLink 파라미터, chooser 제목을 새로 정의하지 않는다.
- `app/src/androidTest`, 기기·에뮬레이터 UI 조작, 스크린샷 QA는 이번 범위에서 제외한다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| 내 채널 소유자 | 내 채널 링크를 빠르게 외부 앱으로 공유 | 우측 상단 공유 버튼 탭 | Android 앱 |
| 다른 사람 채널 방문자 | 방문 중인 채널 링크를 외부 앱으로 공유 | 더보기 BottomSheet에서 `채널 공유` 탭 | Android 앱 |
### 권한
- 인증 주체: 현재 앱 사용자.
- 허용 역할: 내 채널 소유자와 다른 사람 채널 방문자 모두 허용.
- 리소스 소유권: `CreatorChannelHeaderUiModel.isOwner`로 UI 진입점을 분기한다.
- 거부 조건: `currentHeader`가 없거나 `creatorId <= 0`인 상태에서는 공유 액션을 실행하지 않는다.
## 6. 핵심 사용자 흐름
1. 사용자가 `CreatorChannelActivity`에 진입한다.
2. Activity가 채널 header를 로드하고 `currentHeader`를 갱신한다.
3. 내 채널이면 우측 상단 `ic_audio_content_share` 버튼이 표시되고, 기존 더보기 버튼은 계속 숨긴다.
4. 다른 사람 채널이면 기존 더보기 버튼이 표시되고, BottomSheet에 `채널 공유` 항목이 추가된다.
5. 공유 액션은 `Intent.ACTION_SEND`, `text/plain`, `Intent.EXTRA_TEXT`를 사용하고 chooser 제목은 `R.string.screen_user_profile_share_channel`을 사용한다.
## 7. 정보 구조와 라우팅
```text
CreatorChannelActivity
내 채널: title_action_container > iv_share
다른 사람 채널: iv_more > CreatorChannelMoreBottomSheet > tv_channel_share
공유 실행: Android ACTION_SEND chooser
```
- 딥링크 payload는 기존 프로필 공유와 동일하게 `deep_link_value=channel`, `deep_link_sub5={creatorId}`를 사용한다.
- 공유 메시지는 기존 `소다라이브 ${nickname}님의 채널입니다.\n{shareUrl}` 형식을 유지한다.
## 8. 기능 요구사항
### 8.1 채널 공유 진입점
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `SHARE-001` | 확정 | 내 채널은 우측 상단에 공유 버튼을 바로 표시한다. | `header.isOwner == true`일 때 `iv_share`가 보이고 `iv_more`, follow, bell은 숨겨진다. | `P1-T1` |
| `SHARE-002` | 확정 | 내 채널 우측 상단 공유 버튼은 `ic_audio_content_share`를 사용한다. | `activity_creator_channel.xml`의 공유 버튼 source가 `@drawable/ic_audio_content_share`다. | `P1-T1` |
| `SHARE-003` | 확정 | 다른 사람 채널은 더보기 BottomSheet에 `채널 공유` 항목을 표시한다. | `header.isOwner == false`일 때 기존 더보기 진입 후 `tv_channel_share` 항목이 표시된다. | `P1-T2` |
| `SHARE-004` | 확정 | 공유 실행은 기존 프로필 공유와 같은 Android chooser 흐름을 사용한다. | `Intent.ACTION_SEND`, `text/plain`, `Intent.EXTRA_TEXT`, `Intent.createChooser(..., screen_user_profile_share_channel)`가 사용된다. | `P1-T3` |
### 8.2 기존 동작 보존
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `REG-001` | 확정 | 다른 사람 채널의 follow, bell, 더보기 표시 정책은 유지한다. | `bindTitleBar()`의 non-owner follow/bell/more 조건이 기존과 동일하다. | `P1-GATE` |
| `REG-002` | 확정 | 더보기 BottomSheet의 차단, 유저 신고, 프로필 신고 동작은 유지한다. | 기존 callback 3개가 dismiss 후 동일하게 호출된다. | `P1-GATE` |
## 9. 반응형 기능 범위
| 기능 | Android Phone | 비고 |
|---|---:|---|
| 내 채널 공유 버튼 | 전체 | 기존 title bar 안에 24dp 아이콘으로 배치 |
| 다른 사람 채널 공유 항목 | 전체 | 기존 BottomSheet vertical menu에 항목 추가 |
## 10. UI/UX Expectations
- 내 채널 공유 버튼은 기존 우측 title action container 안에서 `iv_more`와 같은 크기·정렬 정책을 따른다.
- 다른 사람 채널 BottomSheet의 `채널 공유` 항목은 기존 block/report 항목과 같은 typography, padding, 색상을 따른다.
- contentDescription은 기존 title action button 관례가 없으므로 기능 label을 연결할 수 있으면 `screen_user_profile_share_channel`을 사용한다.
## 11. API 계약
- 신규 API 없음.
- OneLink 생성은 기존 `Utils.createOneLinkUrl(params = mapOf("af_dp" to "voiceon://", "deep_link_value" to "channel", "deep_link_sub5" to "$creatorId"))` 흐름과 동일하다.
## 12. 보안과 데이터 취급
- 공유 텍스트에는 채널 닉네임과 공개 채널 링크만 포함한다.
- `BuildConfig` 값, token, signed URL, 개인정보성 내부 ID를 log, Toast, crash message에 노출하지 않는다.
- 내부 `creatorId`는 기존 공유 딥링크의 `deep_link_sub5` 값으로만 사용한다.
## 13. 성능과 품질 요구사항
- 네트워크 요청 없이 로컬 OneLink URL 생성과 Android chooser 실행만 수행한다.
- 신규 dependency를 추가하지 않는다.
- 자동 테스트는 `app/src/test`의 source/local unit test만 계획한다.
- 전체 UI 계측 테스트와 스크린샷 QA는 사용자 명시 요청 전까지 실행하지 않는다.
## 14. 성공 기준
### 14.1 기능 수용 기준
- [x] 내 채널에서 우측 상단 공유 버튼이 표시된다. (`SHARE-001`, `P1-T1`)
- [x] 내 채널 공유 버튼이 `ic_audio_content_share`를 사용한다. (`SHARE-002`, `P1-T1`)
- [x] 다른 사람 채널 더보기 BottomSheet에 `채널 공유`가 표시된다. (`SHARE-003`, `P1-T2`)
- [x] 두 진입점 모두 기존 프로필 공유와 같은 chooser payload를 사용한다. (`SHARE-004`, `P1-T3`)
### 14.2 회귀 수용 기준
- [x] non-owner follow, bell, more 표시 정책이 유지된다.
- [x] 기존 block/report/profile report BottomSheet callback이 유지된다.
- [x] `UserProfileActivity`, `UserProfileViewModel`은 수정되지 않는다.
검증 기록: 2026-09-14 `CreatorChannelShareSourceTest`, `:app:mergeDebugResources`, `:app:assembleDebug`, `git diff --check`, review PASS 기준으로 체크했다. 2026-09-15 `ISSUE-001` 후속으로 `SharedPreferenceManager.can` 테스트 격리를 수정했고 `SharedPreferenceManagerTest`, `creator.channel.*`, `CreatorChannelShareSourceTest`, `git diff --check` PASS를 확인했다. 실제 앱 수동 테스트는 `plan-task.md`의 Phase 1 Gate 수동 검증에 별도 대기 항목으로 남긴다.
## 15. Open Questions
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal |
|---|---|---|---|---|---|---|
| 없음 | 확정 | 추가 인터뷰 필요 항목 없음 | 현재 요구사항 그대로 구현 | 사용자 | 문서 작성 시점 | 없음 |
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `SHARE-001~002` | 없음 | 1 | `P1-T1` | `CreatorChannelShareSourceTest` | 내 채널 title bar 확인 |
| `SHARE-003` | 없음 | 1 | `P1-T2` | `CreatorChannelShareSourceTest` | 타인 채널 BottomSheet 확인 |
| `SHARE-004`, `REG-001~002` | 없음 | 1 | `P1-T3`, `P1-GATE` | `CreatorChannelShareSourceTest` | Android chooser 실행 확인 |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| `2026-09-14` | `DEC-001` | 확정 | 내 채널은 우측 상단 직접 공유 버튼, 타인 채널은 더보기 BottomSheet 공유 항목으로 분기한다. | 사용자 요청 | `SHARE-001~003`, `P1-T1~T2` |
| `2026-09-14` | `DEC-002` | 확정 | 공유 실행은 `UserProfileActivity` 887~902줄의 Android chooser 흐름과 동일하게 사용한다. | 사용자 요청 및 기존 코드 확인 | `SHARE-004`, `P1-T3` |
| `2026-09-14` | `DEC-003` | 확정 | 레거시 프로필 파일은 수정하지 않고, 크리에이터 채널 v2 범위에서 기존 공유 방식만 재현한다. | 저장소 레거시 코드 사용 원칙 | `REG-002`, `P1-GATE` |
## 18. 변경 관리
- 요구사항 변경 시 Decision Log에 정정 행을 추가한다.
- 구현 범위가 바뀌면 `plan-task.md` 체크리스트를 먼저 갱신한다.
- 기존 완료 체크박스와 검증 기록은 삭제하거나 덮어쓰지 않는다.
@@ -0,0 +1,105 @@
# 크리에이터 채널 공유 Phase 1 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`~`P1-GATE` |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | `2026-09-14` |
| 리뷰어 | Sisyphus + subagent reviewer |
| 기준 문서 | `docs/20260914_크리에이터_채널_공유/prd.md`, `docs/20260914_크리에이터_채널_공유/plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- Phase 1 구현이 `SHARE-001~004`, `REG-001~002` 요구사항을 충족하는지 확인한다.
- 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.
### 포함 범위
- 코드: `CreatorChannelActivity.kt`, `CreatorChannelMoreBottomSheet.kt`
- 레이아웃: `activity_creator_channel.xml`, `dialog_creator_channel_more.xml`
- 테스트: `CreatorChannelShareSourceTest.kt`
- 문서: `prd.md`, `plan-task.md`
### 제외 범위
- 레거시 `UserProfileActivity`, `UserProfileViewModel` 수정.
- 신규 API, analytics, dependency 추가.
- 시각 QA, 스크린샷, `app/src/androidTest` 계측 테스트.
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 공유 핵심 흐름 불능, 빌드 실패, 레거시 파일 수정, 요구사항 위반 |
| High | owner/non-owner 진입점 오류 또는 기존 더보기 callback 회귀 |
| Medium | 제한 조건에서 공유 payload 또는 접근성 label 오류 |
| Low | 문서 정합성, 테스트 격리성, 비핵심 유지보수 문제 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `SHARE-001~004`, `REG-001~002`
- 계획: `P1-T1`, `P1-T2`, `P1-T3`, `P1-GATE`
- 코드: `CreatorChannelActivity.kt:231~469`, `CreatorChannelMoreBottomSheet.kt:12~48`
- 레이아웃: `activity_creator_channel.xml:229~245`, `dialog_creator_channel_more.xml:19~57`
- 테스트: `CreatorChannelShareSourceTest`
### 실행 환경
```text
OS: macOS / darwin
검증 범위: Android Gradle local unit/resource/build
수동 검증: 사용자 실제 앱 테스트 대기
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest"` | RED 성공 | production 변경 전 3개 테스트가 기능 부재 assertion으로 실패 |
| `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelShareSourceTest" --rerun-tasks` | 성공 | `BUILD SUCCESSFUL`, 39 tasks executed |
| `./gradlew :app:mergeDebugResources` | 성공 | `BUILD SUCCESSFUL` |
| `./gradlew :app:assembleDebug` | 성공 | `BUILD SUCCESSFUL`, 기존 deprecation warning만 출력 |
| `git diff --check` | 성공 | 출력 없음 |
| `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"` | 실패 | `CreatorChannelHomeViewModelTest` 후원 can assertion 1건 실패. 실패 후보 테스트 2개는 단독 실행 PASS |
| 실제 앱 수동 테스트 | 대기 | 사용자 지시에 따라 시각 QA 대신 수동 확인 예정 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| 없음 | - | - | 확정 발견 사항 없음 | - | - |
확정 발견 사항 없음. 패키지 회귀의 후원 can assertion 실패는 이번 공유 변경과 관련 파일이 겹치지 않고 단독 실행 PASS하므로 차단 이슈로 판정하지 않았다.
## 6. 발견 사항 상세
확정 발견 사항 없음.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 변경 파일과 기준 문서 대조 완료 |
| 후보 항목 판정 완료 | 충족 | 차단 발견 사항 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 보류 항목의 담당·재개 조건 기록 | 충족 | 실제 앱 수동 테스트는 사용자 확인 대기로 기록 |
| 검증 명령과 결과 기록 | 충족 | 실행 명령과 결과를 `plan-task.md`와 이 문서에 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 실제 앱에서 owner/non-owner 공유 진입점과 기존 더보기 메뉴를 수동 확인한다.
## 9. 수정 후 검증 기록
수정 goal 없음.
@@ -0,0 +1,847 @@
# 크리에이터의 리스너 DM 시작 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | `2026-09-14` |
| 요구사항 기준 | `docs/20260914_크리에이터의_리스너_DM_시작/prd.md` |
| API 기준 | 서버 `CreateUserCreatorChatRoomRequest(recipientId?, creatorId?)` 계약 제공됨 |
| 현재 Phase | Phase 5 완료 |
| 현재 활성 Goal | 없음 |
## 목표
크리에이터가 메인 대화 탭에서 리스너를 선택하고 기존 DM 생성 흐름으로 먼저 대화를 시작할 수 있게 한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
| 2 | 완료 | `4/4` | 없음 | 없음 |
| 3 | 완료 | `1/1` | 없음 | 없음 |
| 4 | 완료 | `2/2` | 없음 | 없음 |
| 5 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
- 사용자가 명시적으로 요청하지 않으면 goal에 token budget을 설정하지 않는다.
## 범위
### 포함
- 메인 대화 탭 우측 하단 `+` 버튼 추가 계획.
- 크리에이터 역할 사용자에게만 신규 DM 시작 진입점 노출.
- Figma `2372:23496` 기준 수신자 선택 화면.
- 최초 진입 시 기존 팔로우 리스트 API 호출.
- `GET /member/search`와 `nickname`을 사용하는 2글자 이상 500ms debounce 검색.
- 검색 결과 empty 문구와 모든 신규 문구의 다국어 리소스 처리.
- Figma `2372:23521` 기준 확인 팝업, v2 공통 다이얼로그 재사용.
- 기존 크리에이터 채널 DM 생성 API 재사용, 신규 리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId` request 계약 유지.
- DM 방에서 뒤로 돌아와 대화 탭이 다시 보일 때 현재 filter의 대화방 목록 첫 페이지 갱신.
- Figma `2372:23496`, `2372:23506` 기준 수신자 선택/검색 결과 화면 상단 toolbar와 `검색결과 N` 라벨 정합성 수정.
- 수신자 선택 화면에서 팔로워 0명일 때 `팔로워` 섹션 문구 숨김.
- `검색결과 0`과 `팔로워` 문구 크기 통일, 검색결과 숫자만 `gray/500` 색상 적용.
### 제외
- 이 문서 작성 단계의 코드 구현.
- 신규 백엔드 endpoint 추가.
- DM 메시지 화면, 소켓, 음성 메시지 정책 변경.
- 팔로우/검색 결과의 프로필 상세, 차단, 팔로우 토글 정책 신규 정의.
- `app/src/androidTest`, 기기·에뮬레이터 조작, 스크린샷·시각 QA.
- DM 방 내부 메시지 읽음/전송/소켓 상태 변경.
## 기술적 제약
- 기술 스택: Android, Kotlin, RxJava, Retrofit, LiveData, Koin 기반 기존 구조.
- 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 레거시 코드는 v2 wrapper/adapter를 우선 사용한다. 기능 구현에 불가피한 경우 최소 수정만 허용한다.
- `CreateDmChatRoomRequest` 계약 변경은 `recipientId` 신규 경로와 기존 `creatorId` 크리에이터 채널 DM 경로의 회귀를 반드시 포함한다.
- 제공되지 않은 endpoint, DTO, enum, 오류 status/key를 추정하지 않는다.
- 테스트 범위는 `app/src/test`의 로직/local unit test로 한정한다.
- UI 변경 자동 테스트는 adapter 분기, mapper, formatter, presentation model, 라우팅처럼 화면 표현을 결정하는 로직만 검증한다. 레이아웃 크기·간격·constraint·visibility 직접 검증은 하지 않는다.
- 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서를 따른다.
- v2 대화 목록 첫 페이지 갱신은 cursor 기반 API의 `cursor = null` 요청으로 검증한다. 별도 page counter를 추가하지 않는다.
## Task TDD 작성 규칙
- [ ] **RED:** 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** focused test를 실행해 요구 동작 미구현 때문에 발생한 의도한 assertion 실패를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test·직접 영향 회귀·lint 결과를 Progress에 기록한다.
읽기 전용 계약 확인 Task는 TDD 단계를 형식적으로 만들지 않는다. 대신 `TDD 예외 사유`와 `대체 검증 방법`을 적는다.
## Phase 1. 계약·파일 지도 확정
**Phase 결과:** 구현에 필요한 기존 파일, 신규 파일 후보, 서버 제공 계약 적용 위치, 역할 판정 기준을 확정한다.
**선행조건:** `prd.md`의 확정 요구사항과 Open Questions 확인.
**Phase 완료 조건:** `P1-T1`, `P1-GATE` 완료, 확인 결과를 Progress와 Decision Log에 누적.
### 구현 항목
#### Task 1.1 계약·파일 지도 확인
**Goal 실행 `P1-T1`:** 기존 코드와 서버 제공 계약을 대조해 구현 시작 조건을 확정한다.
- **시작 조건:** `prd.md`의 `DM-001`~`DM-019`, `EXT-001` 해결 상태 확인.
- **완료 증거:** 아래 대조표가 실제 파일 경로와 결정 상태로 갱신되고, 서버 계약 적용 위치가 Decision Log에 기록된다.
- **범위 밖:** production 코드 변경, 임의 debounce 상수 적용, 서버 계약과 다른 request contract 구현.
- **TDD 예외 사유:** 동작 변경 없는 읽기 전용 계약·파일 확인 Task다.
- **대체 검증 방법:** 기존 코드 심볼과 PRD 요구사항을 대조하고 누락·차단 항목을 문서화한다.
**Files:**
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt`
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt`
- Confirm: `app/src/main/java/kr/co/vividnext/sodalive/following/FollowingCreatorRepository.kt`
- Confirm: v2 공통 다이얼로그 실제 파일 경로
- Confirm: 홈 화면 `+` 버튼 실제 파일과 drawable/style 경로
- Confirm: string resource 지원 언어 파일 경로
- Planned Create: v2 수신자 선택 화면 파일
- Planned Test: `app/src/test` 하위 DM request, 검색 상태, 라우팅 test 파일
**Interfaces:**
- Consumes: 기존 팔로우 목록 API, `UserRepository.searchUser`, `DmChatRepository.createOrGetRoom`, v2 공통 다이얼로그.
- Produces: 구현 대상 파일 지도, 서버 계약 상태, 다국어 문구 표.
- [x] 기존 크리에이터 채널 DM 생성 호출부와 request DTO를 확인한다.
- [x] 서버가 제공한 `CreateUserCreatorChatRoomRequest(recipientId?, creatorId?)` 계약을 `EXT-001`에 기록한다.
- [x] 팔로우 리스트 페이지가 사용하는 API와 응답 ID 필드를 확인한다.
- [x] 검색 결과 `GetRoomDetailUser`의 사용자 ID, 닉네임, 프로필 이미지 필드를 확인한다.
- [x] 크리에이터 역할 판정 기준과 메인 대화 탭 진입점 노출 위치를 확인한다.
- [x] v2 공통 다이얼로그 파일과 재사용 가능한 문구 주입 방식을 확인한다.
- [x] 홈 화면 `+` 버튼 리소스와 위치 구현 패턴을 확인한다.
- [x] 확정된 `OQ-001`~`OQ-005` 결정이 실제 구현 파일 지도와 충돌하지 않는지 확인한다.
### 완료 조건
- [x] `P1-T1`의 체크박스와 대체 검증 기록이 완료됐다.
- [x] `EXT-001` 해결 상태와 호출 경로별 request field 사용 기준이 구현 Task의 시작 조건에 반영됐다.
- [x] 실제 파일 경로와 예정 파일 경로가 구분돼 있다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** Phase 2 구현을 시작해도 되는지 문서 기준으로 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** PRD 요구사항, Open Questions, 외부 의존, 파일 지도 대조표 통과.
- **범위 밖:** Gate 통과를 위한 요구사항 삭제 또는 임의 확정.
수동 문서 검증:
- [x] `DM-001`~`DM-019`가 최소 하나의 Phase 2 Task에 연결돼 있다.
- [x] `OQ-001`~`OQ-005`가 모두 확정 상태로 유지된다.
- [x] `EXT-001`이 해결됐고 `P2-T1`은 신규 `recipientId`와 기존 `creatorId` 경로를 모두 검증하도록 표시돼 있다.
- [x] 레거시 파일 변경 필요 여부가 확인돼 있다.
## Phase 2. 기능 구현 계획
**Phase 결과:** 크리에이터가 메인 대화 탭에서 리스너를 선택해 DM을 시작하는 흐름을 구현한다.
**선행조건:** `P1-GATE` 완료.
**Phase 완료 조건:** `P2-T1`~`P2-T3`, `P2-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 2.1 DM 요청 계약 적용
**Goal 실행 `P2-T1`:** DM 방 생성 요청 DTO가 `recipientId`와 `creatorId`를 모두 표현하도록 계약을 적용하고 호출 경로별로 하나의 ID만 보내게 한다.
- **시작 조건:** `P1-GATE` 완료.
- **완료 증거:** 신규 리스너 DM의 `recipientId` request serialization test, 기존 크리에이터 채널 DM의 `creatorId` request serialization 회귀 test, focused test 결과 기록.
- **범위 밖:** 신규 endpoint 추가, DM 메시지 화면 수정.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt` 또는 실제 DTO 파일
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/` 하위 request/repository test
**Interfaces:**
- Consumes: 서버가 제공한 `CreateUserCreatorChatRoomRequest(recipientId: Long? = null, creatorId: Long? = null)` 호환 contract.
- Produces: 신규 리스너 DM용 `CreateDmChatRoomRequest(recipientId = selectedUserId)`, 기존 크리에이터 채널 DM용 `CreateDmChatRoomRequest(creatorId = creatorId)` contract.
- [x] **RED:** 신규 리스너 DM 생성 request를 JSON으로 직렬화했을 때 `recipientId`가 있고 `creatorId`가 없음을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 신규 `recipientId` 경로가 없어 실패함을 확인한다.
- [x] **GREEN:** DTO에 nullable `recipientId`, `creatorId`를 모두 표현하고 신규 리스너 DM 요청 생성 경로만 `recipientId`를 채우도록 최소 구현한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **RED:** 기존 크리에이터 채널 DM 생성 request가 계속 `creatorId`를 보내고 `recipientId`를 보내지 않음을 검증하는 회귀 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 DTO 변경 전후의 기대 차이를 확인한다.
- [x] **GREEN:** 기존 `createOrGetRoom(token, creatorId)` 경로가 `creatorId` contract를 유지하게 한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 신규/기존 DM 생성 경로의 중복만 정리하고 focused test와 lint 결과를 Progress에 기록한다.
#### Task 2.2 팔로우·검색 상태 로직
**Goal 실행 `P2-T2`:** 최초 팔로우 목록과 debounce 검색 상태를 제공한다.
- **시작 조건:** `P1-GATE` 완료.
- **완료 증거:** 팔로우 최초 조회, 0~1자 팔로우 리스트 복귀, 500ms debounce 후 최종 검색어 요청, empty 상태 test 통과.
- **범위 밖:** 화면 레이아웃 속성 직접 검증, 신규 검색 endpoint 추가.
**Files:**
- Planned Create: v2 수신자 선택 ViewModel/state/mapper 파일
- Planned Modify: 기존 repository wrapper 또는 신규 v2 repository adapter
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/` 하위 검색 상태 test
**Interfaces:**
- Consumes: 기존 팔로우 목록 API, `UserRepository.searchUser(nickname, token)`.
- Produces: 팔로우 목록 상태, 검색 결과 상태, 검색 empty 상태, 로딩/오류 상태.
- [x] **RED:** 최초 진입 시 팔로우 목록 API가 1회 호출되는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 호출이 없어 실패함을 확인한다.
- [x] **GREEN:** 최초 상태에서 기존 팔로우 목록 API를 호출하는 최소 로직을 작성한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **RED:** 검색어 0~1자에서는 `/member/search` 요청 0회와 팔로우 리스트 복귀, 2자 이상 연속 입력 중에는 500ms 전 요청 0회와 500ms 후 최종 검색어 1회 요청을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 debounce/길이 조건 미구현으로 실패함을 확인한다.
- [x] **GREEN:** 500ms debounce와 길이 조건으로 검색 로직을 작성한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** empty/error/loading 상태 mapper 중 이번 Task가 만든 중복만 정리하고 focused test 결과를 Progress에 기록한다.
#### Task 2.3 진입·화면·확인 팝업 연결
**Goal 실행 `P2-T3`:** 크리에이터 전용 `+` 버튼, 신규 화면, 확인 팝업, 보내기 액션을 연결한다.
- **시작 조건:** `P2-T1`, `P2-T2` 완료.
- **완료 증거:** 역할별 진입 판단, 문구 resource mapping, 취소/보내기 action, navigation test 통과.
- **범위 밖:** 새 다이얼로그 구현, 기존 DM 방 UI 변경.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- Planned Create: v2 수신자 선택 화면 Activity/Fragment, adapter, layout, ViewModel 연결 파일
- Modify: `app/src/main/res/values/strings.xml` 및 기존 지원 언어 string resource 파일
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/` 하위 라우팅/문구/action test
**Interfaces:**
- Consumes: Phase 2.1 DM 생성 contract, Phase 2.2 수신자 선택 상태, v2 공통 다이얼로그.
- Produces: 크리에이터 전용 진입점, 수신자 선택 화면, 확인 팝업, DM 방 이동 action.
- [x] **RED:** 크리에이터 역할이면 `+` 버튼 진입 action이 활성이고 비크리에이터면 비활성임을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 역할별 분기가 없어 실패함을 확인한다.
- [x] **GREEN:** 메인 대화 탭에 홈과 유사한 `+` 진입점을 최소 변경으로 연결한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **RED:** 사용자 선택 후 팝업 문구가 `메시지 보내기`, `%s에게 메시지를 보낼까요?`, `취소`, `보내기` resource를 사용하고 취소 시 요청 0회임을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 팝업 action 미구현으로 실패함을 확인한다.
- [x] **GREEN:** v2 공통 다이얼로그를 재사용해 문구와 취소/보내기 action을 연결한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **RED:** `보내기` 시 선택한 사용자 ID가 `recipientId`로 전달되고 성공 시 기존 DM 방 이동 action이 실행됨을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 보내기 연결 미구현으로 실패함을 확인한다.
- [x] **GREEN:** 기존 DM 생성 repository/action을 재사용해 보내기와 이동을 연결한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 신규 화면 파일의 이번 Task 중복만 정리하고 focused test·직접 영향 회귀·lint 결과를 Progress에 기록한다.
**P2-T3 API 연결 보완:** 실제 비동기 provider 연결에 필요한 요청 실패 상태와 이전 요청 취소를 같은 Task에서 최소 추가한다. 기존 P2-T2 완료 기록은 유지한다.
- [x] **RED:** 요청 실패 2건과 늦은 팔로워/검색 응답 2건의 회귀 test를 추가하고 실패를 확인한다.
- [x] **GREEN:** 요청 오류를 오류 상태로 전달하고 검색 모드 전환 시 이전 API 구독을 취소한다.
- [x] **검증:** 수신자 focused test와 기존 DM/ChatAction 회귀를 실행한다.
**P2-T3 검증 기록 — 2026-09-14**
- 사용자 승인에 따라 `DESIGN.md` 없이 기존 v2 FAB, toolbar, typography/spacing/color, 프로필 이미지 loader, `V2ModalDialog`를 재사용했다.
- 신규 화면은 기존 repository를 ViewModel factory에서 연결한다. 팔로워는 최초 1페이지 20명, 검색은 기존 정책대로 2글자 이상 500ms 후 실행하며 전체 검색 응답을 표시한다. 이번 최소 구현에는 팔로워 추가 페이지 로딩을 넣지 않았다.
- 취소 무부작용과 확인 후 생성/이동은 요청을 confirm callback 안에만 연결하고 cancel callback을 지정하지 않는 source wiring test로 검증했다. 기기 UI 자동화는 실행하지 않았다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest"`: RED 5개 assertion 실패 확인. 역할/신규 화면/문구/생성 연결 미구현이 원인이다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest"`: 오류 상태 추가 전 `isError` 부재 컴파일 실패, 이후 늦은 응답 회귀 2개 assertion 실패를 각각 확인했다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.*"`: GREEN 성공. 이후 늦은 응답 회귀도 아래 최종 통합 명령에서 통과했다.
- `./gradlew :app:ktlintCheck`: 신규 test 줄바꿈 오류로 최초 실패했고 해당 줄바꿈만 수정했다. 최종 통합 실행에서 성공했다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" :app:assembleDebug :app:ktlintCheck`: 최종 `BUILD SUCCESSFUL`, exit 0. 수신자 13개를 포함한 18개 suite 181개 test 실패/오류 0, APK 및 layout/resource binding 컴파일 성공, ktlint 성공.
- 앞선 test+assemble 통합 실행은 도구 제한 120초로 중단되어 600초 제한으로 재실행했고 성공했다.
- 신규 Kotlin production 비공백 LOC: Activity 89, factory 38, adapter 29. 변경 ViewModel 84, 기존 ChatMainFragment 234. 기존 Fragment는 다음 확장 시 분리 검토가 필요한 200~250 경고 구간이며 이번에는 요청 진입점만 추가했다.
- LSP diagnostics 도구는 제공되지 않아 실행하지 못했으며 Kotlin 컴파일과 ktlint로 검증했다. 외부 서비스 호출, 기기/에뮬레이터, screenshot, androidTest는 요청대로 실행하지 않았다.
### 완료 조건
- [x] `P2-T1`, `P2-T2`, `P2-T3`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] 크리에이터 전용 진입, 팔로우 목록, 검색, empty, 팝업, DM 생성 흐름이 PRD와 일치한다.
- [x] 기존 크리에이터 채널 DM 진입 회귀가 통과했다.
### 검증 방법
#### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 크리에이터의 리스너 DM 시작 흐름과 기존 DM 회귀를 최종 판정한다.
- **시작 조건:** Phase 2의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 focused/영향 범위 검증 통과와 Progress 기록.
- **범위 밖:** 실패와 무관한 리팩터링, 테스트 삭제·완화.
```bash
./gradlew :app:test --tests "*Dm*"
./gradlew :app:test --tests "*Chat*"
./gradlew :app:ktlintCheck
```
**Expected:** 0 exit code. DM request contract, 검색 debounce, 역할별 진입, 팝업 action, 기존 크리에이터 채널 DM 회귀가 통과한다.
전체 회귀가 필요하다고 판단되면 아래 명령을 별도로 실행하고, 생략 시 생략 근거와 focused 검증 결과를 Progress에 기록한다.
```bash
./gradlew :app:test
```
## Phase 3. DM 방 복귀 목록 갱신
**Phase 결과:** 사용자가 DM 방에서 뒤로 돌아와 대화 탭을 다시 보면 현재 filter 기준 최신 대화방 목록 첫 페이지가 표시된다.
**선행조건:** Phase 2 완료, 사용자 확인으로 채팅방 뒤로가기 시 대화 탭 복귀 방식이 기능적으로 맞다고 확정.
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 3.1 DM 방 복귀 시 대화 목록 첫 페이지 갱신
**Goal 실행 `P3-T1`:** 대화 탭이 다시 resume될 때 최초 진입 중복 호출은 피하고, 이후 복귀에서는 기존 표시 목록을 비운 뒤 현재 filter 첫 페이지를 다시 요청한다.
- **시작 조건:** `P2-GATE` 완료와 PRD `DM-020` 확정.
- **완료 증거:** Fragment source wiring RED/GREEN, ViewModel 첫 페이지 reset 회귀 확인, focused test와 영향 범위 검증 결과 기록.
- **범위 밖:** `DmChatRoomActivity` 내부 메시지 정책 변경, 신규 API 추가, 기기·에뮬레이터 UI 조작.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
- Confirm: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt`
**Interfaces:**
- Consumes: `ChatMainViewModel.loadFirstPage(filter)`의 `cursor = null`, 목록 초기화, stale response 방지 contract.
- Produces: 복귀 시 `selectedFilter`를 유지한 첫 페이지 재조회와 기존 표시 목록 clear wiring.
- [x] **RED:** `ChatMainFragment` source가 `onResume()`에서 첫 resume을 skip하고 이후 `cancelChatPullRefresh()`, `bindChatRooms(emptyList(), showEmpty = false)`, `viewModel.loadFirstPage(selectedFilter)`를 호출함을 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused test를 실행해 `onResume()` 복귀 갱신 wiring 미구현으로 실패함을 확인한다.
- [x] **GREEN:** `ChatMainFragment`에 view 생성 시 초기화되는 first-resume guard와 subsequent resume refresh를 최소 구현한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공하고, 기존 `ChatMainViewModelTest`의 첫 페이지 reset 회귀가 통과함을 확인한다.
- [x] **REFACTOR:** 신규 abstraction 없이 중복만 점검하고 focused test·직접 영향 회귀·lint 결과를 Progress에 기록한다.
### 완료 조건
- [x] `P3-T1`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] 최초 대화 탭 진입은 기존 `onViewCreated()` 첫 페이지 요청만 사용하고 중복 resume 요청을 만들지 않는다.
- [x] DM 방 복귀 시 현재 filter 기준 첫 페이지 요청이 발생하고 기존 표시 목록은 먼저 비워진다.
### 검증 방법
#### Phase 3 Gate
**Goal 실행 `P3-GATE`:** DM 방 복귀 대화 목록 갱신과 기존 채팅 회귀를 최종 판정한다.
- **시작 조건:** `P3-T1` 완료.
- **완료 증거:** 아래 focused/영향 범위 검증 통과와 Progress 기록.
- **범위 밖:** 실패와 무관한 UI 정렬, DM 방 내부 로직 수정.
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" :app:assembleDebug :app:ktlintCheck
```
**Expected:** 0 exit code. 복귀 갱신 source wiring, 첫 페이지 `cursor = null` reset, 기존 pagination/filter/action 회귀가 통과한다.
## Phase 4. 수신자 검색 화면 Figma 정합성 수정
**Phase 결과:** 수신자 선택 화면과 검색 결과 화면이 Figma `2372:23496`, `2372:23506`의 상단 검색 toolbar, 섹션/검색결과 라벨, 리스트 행 구조와 일치한다.
**선행조건:** Phase 2 기능 구현 완료, 사용자 지적으로 Figma UI 불일치와 `검색결과 0` 미표시가 확인됨.
**Phase 완료 조건:** `P4-T1`, `P4-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 4.1 수신자 선택·검색 결과 UI 정합성 수정
**Goal 실행 `P4-T1`:** 기존 수신자 선택 기능을 유지하면서 Figma 검색 toolbar 구조와 검색 결과 count 라벨을 적용한다.
- **시작 조건:** PRD `DM-021` 확정과 Figma `2372:23496`, `2372:23506` metadata/screenshot 확인.
- **완료 증거:** layout/source RED/GREEN, 검색 결과 0 count state RED/GREEN, focused test와 영향 범위 검증 결과 기록.
- **범위 밖:** 신규 API, 검색 정책 변경, DM 방 내부 화면 수정, 기기·에뮬레이터 UI 조작.
**Files:**
- Modify: `app/src/main/res/layout/activity_dm_recipient_picker.xml`
- Modify: `app/src/main/res/layout/item_dm_recipient.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerActivity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModel.kt`
- Modify: `app/src/main/res/values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerViewModelTest.kt`
**Interfaces:**
- Consumes: 기존 follower/search/empty/error state와 `DmRecipientAdapter` 목록 표시.
- Produces: Figma형 search toolbar, follower/search count label, 검색 결과 0건 count 표시.
- [x] **RED:** layout/source test가 `detail_toolbar` 미사용, 뒤로가기와 검색 input의 같은 toolbar row, `tv_search_result_count`, row 66dp와 profile 42dp, count string resource를 검증하도록 작성한다.
- [x] **RED 확인:** focused source test를 실행해 현재 layout/Activity/string wiring 미구현으로 실패함을 확인한다.
- [x] **RED:** ViewModel test가 검색 결과 0건에서도 `searchResultCount = 0`을 state에 담는지 검증하도록 작성한다.
- [x] **RED 확인:** focused ViewModel test를 실행해 count field 부재 또는 값 미구현으로 실패함을 확인한다.
- [x] **GREEN:** XML, Activity, ViewModel, 문자열 리소스를 최소 수정해 RED를 통과시킨다.
- [x] **GREEN 확인:** 같은 focused tests를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 신규 abstraction 없이 중복만 점검하고 focused test·직접 영향 회귀·build/ktlint 결과를 Progress에 기록한다.
#### Task 4.2 리뷰 후속 회귀 수정
**Goal 실행 `P4-R1`:** Oracle 리뷰에서 확인된 검색 empty 위치, 레이아웃 속성 직접 검증, 대화 탭 resume guard 재생성 회귀를 최소 수정한다.
- **시작 조건:** `P4-T1` 완료 후 read-only 리뷰 결과 확인.
- **완료 증거:** 회귀 RED/GREEN, 관련 focused test와 Phase 4 Gate 재실행 결과 기록.
- **범위 밖:** Figma 치수 자동 검증 추가, 기기·에뮬레이터 UI 조작, 화면 전체 재설계.
**Files:**
- Modify: `app/src/main/res/layout/activity_dm_recipient_picker.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
- Modify: `docs/20260914_크리에이터의_리스너_DM_시작/prd.md`, `plan-task.md`
- [x] **RED:** 검색 empty가 리스트 콘텐츠 영역에서 전환되는 구조와 `ChatMainFragment` view 재생성 시 resume guard 초기화를 검증하는 실패 test를 작성한다.
- [x] **RED 확인:** focused tests를 실행해 현재 XML sibling 구조와 guard 초기화 부재로 실패함을 확인한다.
- [x] **GREEN:** empty/list 콘텐츠 컨테이너와 `onViewCreated()` guard 초기화를 최소 수정하고, 레이아웃 치수 직접 assertion을 제거한다.
- [x] **GREEN 확인:** 같은 focused tests를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 문서 상태와 issue 해결 상태를 최신화하고 Phase 4 Gate를 재실행한다.
### 완료 조건
- [x] `P4-T1`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] Figma `2372:23496` 기본 화면은 한 줄 검색 toolbar와 `팔로워` 섹션 및 66dp row 구조를 사용한다.
- [x] Figma `2372:23506` 검색 화면은 검색 결과가 0건이어도 `검색결과 0`을 표시한다.
- [x] 기존 debounce, empty state, 확인 팝업, DM 생성 흐름이 유지된다.
### 검증 방법
#### Phase 4 Gate
**Goal 실행 `P4-GATE`:** Figma 수신자 검색 UI 정합성과 기존 수신자 기능 회귀를 최종 판정한다.
- **시작 조건:** `P4-T1` 완료.
- **완료 증거:** 아래 focused/영향 범위 검증 통과와 Progress 기록.
- **범위 밖:** 실패와 무관한 전체 디자인 시스템 개편.
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest"
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.*" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" :app:assembleDebug :app:ktlintCheck
```
**Expected:** 0 exit code. Figma source contract, `검색결과 0` state, 기존 recipient flow와 DM request 회귀가 통과한다.
## Phase 5. 팔로워 0명 섹션 라벨 숨김
**Phase 결과:** 수신자 선택 화면의 팔로워 목록이 비어 있으면 `팔로워` 섹션 라벨을 표시하지 않고, `검색결과 0`과 `팔로워` 라벨 스타일이 요구사항과 일치한다.
**선행조건:** Phase 4 수신자 선택 화면 구현 완료, 사용자 후속 요구 `DM-022`, `DM-023` 확정.
**Phase 완료 조건:** `P5-T1`, `P5-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 5.1 팔로워 라벨 표시 조건 수정
**Goal 실행 `P5-T1`:** 팔로워 모드라도 표시할 수신자가 0명이면 `팔로워` 라벨을 숨기고, `검색결과 0`과 `팔로워` 라벨의 문구 크기와 검색결과 숫자 색상을 맞춘다.
- **시작 조건:** PRD `DM-022`, `DM-023` 확정.
- **완료 증거:** source/layout RED/GREEN, focused test와 영향 범위 검증 결과 기록.
- **범위 밖:** 검색 결과 count 정책, 팔로워 empty 문구 신규 정의, 기기·에뮬레이터 UI 조작.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerActivity.kt`
- Modify: `app/src/main/res/layout/activity_dm_recipient_picker.xml`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt`
- Modify: `docs/20260914_크리에이터의_리스너_DM_시작/prd.md`, `plan-task.md`
- [x] **RED:** source test가 `tvFollowers` 표시 조건이 팔로워 모드와 수신자 목록 non-empty 조건을 함께 사용하는지, layout/source test가 두 라벨 style 통일과 검색결과 숫자 `gray_500` span을 검증하도록 작성한다.
- [x] **RED 확인:** focused source test를 실행해 현재 팔로워 모드만 보는 조건, 라벨 style 불일치, 숫자 span 부재 때문에 실패함을 확인한다.
- [x] **GREEN:** Activity binding과 XML을 최소 수정해 팔로워 0명 라벨 숨김, 라벨 크기 통일, 검색결과 숫자 `gray_500` span을 적용한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [x] **REFACTOR:** 신규 state나 abstraction 없이 중복만 점검하고 focused test·직접 영향 회귀·build/ktlint 결과를 Progress에 기록한다.
### 완료 조건
- [x] `P5-T1`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] 팔로워 모드에서 수신자가 0명이면 `팔로워` 라벨이 숨겨진다.
- [x] `검색결과 0`과 `팔로워` 라벨의 style이 같고 검색결과 숫자 범위에만 `@color/gray_500` span을 적용한다.
- [x] 검색 모드의 `검색결과 N`, empty state, 기존 DM 생성 흐름이 유지된다.
### 검증 방법
#### Phase 5 Gate
**Goal 실행 `P5-GATE`:** 팔로워 라벨 숨김과 기존 수신자 기능 회귀를 최종 판정한다.
- **시작 조건:** `P5-T1` 완료.
- **완료 증거:** 아래 focused/영향 범위 검증 통과와 Progress 기록.
- **범위 밖:** 기기·에뮬레이터 UI 조작, screenshot, visual QA.
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest"
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.*" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" :app:assembleDebug :app:ktlintCheck
```
**Expected:** 0 exit code. 팔로워 라벨 source contract, 라벨 typography/숫자 color contract, 기존 recipient flow와 DM request 회귀가 통과한다.
## Phase 6. 메인 대화 탭 신규 DM 아이콘 변경
**Phase 결과:** 크리에이터에게만 보이는 메인 대화 탭 우측 하단 버튼이 `ic_new_talk_add` 아이콘을 사용한다.
**선행조건:** 기존 `btn_new_dm` 노출 조건과 클릭 동작을 그대로 유지한다.
**Phase 완료 조건:** `P6-T1` 완료와 리소스 merge 검증 기록 누적.
### 구현 항목
#### Task 6.1 신규 DM 버튼 아이콘 리소스 교체
**Goal 실행 `P6-T1`:** `fragment_v2_main_chat.xml`의 `btn_new_dm` 아이콘 참조만 `ic_plus_no_bg`에서 `ic_new_talk_add`로 변경한다.
- **완료 증거:** XML 참조 확인, `:app:mergeDebugResources`, 관련 layout test, `git diff --check` 통과.
- **범위 밖:** 버튼 크기·배경·노출 조건·클릭 동작과 다른 기능 변경.
**Files:**
- Modify: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- Modify: `docs/20260914_크리에이터의_리스너_DM_시작/prd.md`, `plan-task.md`
- [x] `btn_new_dm`의 `android:src`만 `@drawable/ic_new_talk_add`로 변경한다.
- [x] 리소스 merge와 관련 layout test를 실행하고 결과를 `Verification Log`에 기록한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | 서버 계약 확인 |
| 2 | `P1-GATE` | `P1-T1` | 아니요 | 누락 요구사항 문서 보정 |
| 3 | `P2-T1` | `P1-GATE` | 아니요 | DTO 적용 위치 확인 |
| 4 | `P2-T2` | `P1-GATE` | `P2-T1` 이후 일부 병행 가능 | 팔로우/검색 상태 로직 확인 |
| 5 | `P2-T3` | `P2-T1`, `P2-T2` | 아니요 | 선행 contract 또는 상태 로직 완료 |
| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 7 | `P3-T1` | `P2-GATE` | 아니요 | 복귀 refresh contract 재확인 |
| 8 | `P3-GATE` | `P3-T1` | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 9 | `P4-T1` | `P2-GATE` | 아니요 | Figma UI contract 재확인 |
| 10 | `P4-GATE` | `P4-T1` | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 11 | `P6-T1` | 기존 `btn_new_dm` 구현 | 아니요 | 신규 drawable 존재와 XML 참조 확인 |
```text
P1-T1 -> P1-GATE -> P2-T1 -> P2-T2 -> P2-T3 -> P2-GATE -> P3-T1 -> P3-GATE -> P4-T1 -> P4-GATE -> P6-T1
```
## 변경 금지 항목
- 코드 구현 전 PRD와 이 계획 문서를 먼저 갱신하지 않고 범위를 변경하지 않는다.
- 제공되지 않은 서버 오류 status/key를 추정하지 않는다.
- 레거시 코드는 v2 wrapper/adapter를 우선 사용하고, 기능 구현에 불가피한 경우 최소 수정만 허용한다.
- 신규 백엔드 endpoint나 신규 다이얼로그 구현을 만들지 않는다.
- 테스트를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과시키지 않는다.
- token, Authorization header, 민감 식별자를 log·Toast·fixture·문서에 기록하지 않는다.
## 의사결정 및 중단 규칙
- PRD와 서버 계약이 충돌하면 `EXT-001`을 갱신하고 구현을 중단한다.
- `OQ-001`~`OQ-005` 결정과 충돌하는 구현 변경이 필요하면 PRD와 이 계획을 먼저 갱신한다.
- 안전한 최소 기본값이 문서에 없으면 임의 구현하지 않는다.
- 같은 차단 사유가 3회 반복되고 독립 문서화도 불가능하면 goal을 `blocked`로 갱신한다.
- 완료 증거와 Progress 기록까지 충족한 뒤에만 goal을 완료로 표시한다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### 문서 작성 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: PRD와 구현 계획 초안을 생성했다.
- 왜: 사용자 요청에 따라 코드 구현 전 요구사항과 계획 문서를 준비했다.
- 어떻게:
- Figma `2372:23496` metadata/screenshot 확인 — 검색바, 팔로우 섹션, 사용자 리스트 구조 확인.
- Figma `2372:23521` metadata/screenshot 확인 — 확인 팝업 제목, 본문, 취소/보내기 구조 확인.
- 기존 코드 심볼 확인 — `ChatMainFragment`, `DmChatApi`, `DmChatRepository`, `UserApi.searchUser`, `UserRepository.searchUser`, `FollowingCreatorRepository` 근거 확인.
- 사용자 인터뷰 — 진입점은 크리에이터에게만 노출한다고 확정.
- 남은 항목: 서버 계약은 이후 `EXT-001`에서 해결됨.
- 다음 행동: 구현 요청이 들어오면 `P1-T1`부터 시작한다.
### Open Questions 확정 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: `OQ-001`~`OQ-005`를 사용자 인터뷰로 확정하고 PRD/계획 문서에 반영했다.
- 왜: 구현 전 검색 호출 기준, 화면 상태 전환, 선택 가능 사용자 범위, 문구, 레거시 수정 허용 범위를 고정하기 위해서다.
- 어떻게:
- `OQ-001` — debounce `500ms` 확정.
- `OQ-002` — 검색어 0~1자에서 팔로우 리스트 복귀 확정.
- `OQ-003` — API 검색 결과 모두 선택 가능 확정.
- `OQ-004` — Figma 한국어 문구 최종 확정.
- `OQ-005` — 레거시 수정이 필요하면 최소 수정 허용 확정.
- 남은 항목: 서버 계약은 이후 `EXT-001`에서 해결됨.
- 다음 행동: 구현 요청이 들어오면 `P1-T1`에서 파일 지도와 서버 계약을 확인한다.
### 서버 계약 반영 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을: 서버가 `recipientId`와 기존 `creatorId`를 모두 수용하는 호환 계약을 제공한 사실을 PRD/계획 문서에 반영했다.
- 왜: `EXT-001` 차단을 해소하고 신규 리스너 DM과 기존 크리에이터 채널 DM의 request field 기준을 분리하기 위해서다.
- 어떻게:
- 신규 크리에이터→리스너 DM은 `recipientId`만 사용하도록 계획했다.
- 기존 크리에이터 채널 DM은 `creatorId`를 유지하도록 계획했다.
- `P2-T1` 테스트 기준을 신규 `recipientId` serialization과 기존 `creatorId` 회귀 검증으로 갱신했다.
- 남은 항목: 없음. 구현 요청이 들어오면 `P1-T1`에서 실제 파일 지도 확인 후 시작한다.
### P1 계약·파일 지도 확인 — 2026-09-14
- 상태: 완료
- 무엇을: 구현 전 파일 지도와 재사용 패턴을 확정했다.
- 왜: Phase 2 구현이 기존 API, role, v2 modal, FAB, string resource 관례와 충돌하지 않게 하기 위해서다.
- 어떻게:
- `CreateDmChatRoomRequest`는 `DmChatModels.kt`, DM 생성 위임은 `DmChatRepository.kt`에 있음을 확인했다.
- 기존 크리에이터 채널 DM은 `createOrGetRoom(token, creatorId)`와 `DmChatRoomViewModel`의 `creatorId` 진입 경로를 사용함을 확인했다.
- 팔로우 리스트는 `ExplorerApi.getFollowerList()`의 `items.userId/profileImage/nickname` 응답을 사용할 수 있음을 확인했다.
- 검색은 기존 `UserRepository.searchUser(nickname, token)`와 `/member/search?nickname=` 경로를 사용함을 확인했다.
- 역할 판정은 `SharedPreferenceManager.role == MemberRole.CREATOR.name`, 공통 팝업은 `V2ModalDialog` 재사용으로 확정했다.
- 검증: 코드 심볼 확인과 백그라운드 탐색 결과 대조를 완료했다.
- 남은 항목: `P2-T2` 팔로우·검색 상태 로직 구현.
### P2-T1 DM 요청 계약 적용 — 2026-09-14
- 상태: 완료
- 무엇을: `CreateDmChatRoomRequest`가 `recipientId`와 `creatorId`를 모두 표현하고, 신규 리스너 DM 생성용 repository 경로를 추가했다.
- 왜: 신규 크리에이터→리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId`를 보내야 하기 때문이다.
- 어떻게:
- RED: `CreateDmChatRoomRequest(recipientId = 22L)` 테스트가 `recipientId` 파라미터 부재로 컴파일 실패함을 확인했다.
- GREEN: DTO를 nullable `recipientId`, nullable `creatorId`로 변경했다.
- RED: `createOrGetRoomByRecipientId` 호출 테스트가 메서드 부재로 컴파일 실패함을 확인했다.
- GREEN: `DmChatRepository.createOrGetRoomByRecipientId(token, recipientId)`를 추가했다.
- 회귀: 기존 `createOrGetRoom(token, creatorId)`와 `creatorId` only 직렬화를 함께 검증했다.
- 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest"` 성공.
- 남은 항목: lint/전체 회귀는 Phase 2 Gate에서 실행한다.
### P2-T2 팔로우·검색 상태 로직 — 2026-09-14
- 상태: 완료
- 무엇을: 수신자 선택용 `DmRecipientPickerViewModel`과 UI state를 추가했다.
- 왜: 메인 대화 탭의 신규 DM 시작 화면에서 최초 팔로우 목록, 0~1자 복귀, 2자 이상 500ms 검색, 검색 empty 상태를 제공하기 위해서다.
- 어떻게:
- RED: 신규 ViewModel/state 부재로 최초 팔로우 목록 로드 test 컴파일 실패를 확인했다.
- GREEN: `enter()`에서 팔로우 목록 provider를 1회 호출하고 `FOLLOWERS` state를 발행했다.
- RED: `onSearchQueryChanged`와 `debounceScheduler` 부재로 검색 test 컴파일 실패를 확인했다.
- GREEN: 0~1자 입력 시 pending 검색을 취소하고 팔로우 목록으로 복귀, 2자 이상은 500ms 후 마지막 검색어만 요청하도록 구현했다.
- RED/GREEN: 검색 결과가 비어 있으면 `isSearchEmpty = true`가 되도록 상태를 추가했다.
- 검증:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest"` 성공.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest"` 성공.
- 신규 production 파일 pure LOC: `72`, 신규 test 파일 pure LOC: `115`.
- 남은 항목: `P2-T3`에서 실제 화면, repository provider, 확인 팝업, DM 방 이동 action을 연결한다.
### P3-T1 DM 방 복귀 목록 갱신 — 2026-09-14
- 상태: 완료
- 무엇을: `ChatMainFragment`가 최초 resume은 건너뛰고, 이후 resume에서 기존 표시 목록을 비운 뒤 현재 filter의 첫 페이지를 다시 요청하도록 연결했다.
- 왜: 사용자가 확인한 뒤로가기 복귀 방식은 유지하되, 복귀 후 대화 탭 목록이 최신 방 목록으로 갱신되어야 하기 때문이다.
- 어떻게:
- RED: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"` 실행 결과 `채팅방에서 복귀하면 현재 filter 첫 페이지로 목록을 갱신한다` 1개 test가 `onResume()` 복귀 갱신 wiring 부재로 실패했다.
- GREEN: `ChatMainFragment`에 `shouldRefreshChatRoomsOnResume` guard를 추가하고 subsequent `onResume()`에서 `cancelChatPullRefresh()`, `bindChatRooms(emptyList(), showEmpty = false)`, `viewModel.loadFirstPage(selectedFilter)`를 호출했다.
- GREEN 확인: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"` 성공.
- 영향 범위 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" :app:assembleDebug :app:ktlintCheck` 성공, `BUILD SUCCESSFUL`.
- `git diff --check`: 출력 없음.
- 검증 보완:
- `ChatMainViewModel.loadFirstPage()`는 기존 구현과 test에서 `cursor = null`, `currentItems = emptyList()`, 결과 replace를 이미 보장한다.
- `ChatMainFragment.kt` pure LOC는 245로 200~250 경고 구간이다. 이번 변경은 기존 파일에 최소 lifecycle hook만 추가했고, 다음 확장 시 분리 검토가 필요하다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
### P4-T1 수신자 선택·검색 결과 UI 정합성 수정 — 2026-09-14
- 상태: 완료
- 무엇을: 수신자 선택 화면의 상단 구조를 Figma형 한 줄 검색 toolbar로 바꾸고 검색 모드에서 `검색결과 N` 라벨을 표시하도록 수정했다.
- 왜: 기존 화면이 별도 `detail_toolbar` 아래 검색창을 사용해 Figma `2372:23496`, `2372:23506`과 다르고, 검색 결과가 0건일 때 `검색결과 0`이 보이지 않았기 때문이다.
- 어떻게:
- RED: `DmRecipientPickerSourceTest`에 `detail_toolbar` 미사용, `btn_back`/검색 input 동일 toolbar row, `tv_search_result_count`, toolbar 54dp, 검색 input 42dp, section 40dp, row 66dp, profile 42dp, 다국어 count string resource 검증을 추가했다.
- RED: `DmRecipientPickerViewModelTest`에 검색 성공/0건 검색 결과에서 `searchResultCount` state를 검증하는 test를 추가했고, count field 부재로 실패를 확인했다.
- GREEN: `activity_dm_recipient_picker.xml`, `item_dm_recipient.xml`, `DmRecipientPickerActivity`, `DmRecipientPickerViewModel`, `strings.xml`/`values-en`/`values-ja`를 최소 수정해 검색 toolbar와 count 표시를 연결했다.
- GREEN 보완: source contract의 count string 참조와 기존 검색 성공 expected state의 `searchResultCount` 누락을 수정했다.
- 검증:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest"` 성공.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.*" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" :app:assembleDebug :app:ktlintCheck` 성공, `BUILD SUCCESSFUL`.
- `git diff --check` 출력 없음.
- 변경 Kotlin 파일 pure LOC: `DmRecipientPickerActivity.kt` 93, `DmRecipientPickerViewModel.kt` 86, `DmRecipientPickerSourceTest.kt` 103, `DmRecipientPickerViewModelTest.kt` 177.
- 검증 보완:
- 기기·에뮬레이터 조작, screenshot, visual QA, `androidTest`는 사용자 명시 요청이 없어 저장소 지침대로 실행하지 않았다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
### P4-R1 리뷰 후속 회귀 수정 — 2026-09-14
- 상태: 완료
- 무엇을: read-only 리뷰에서 확인된 검색 empty 위치, 레이아웃 속성 직접 검증, 대화 탭 resume guard 재생성 회귀를 수정했다.
- 왜: `DM-010` 중앙 empty state와 저장소의 레이아웃 속성 직접 검증 금지, `P3-T1`의 첫 resume 중복 요청 방지 조건을 동시에 만족해야 하기 때문이다.
- 어떻게:
- RED: `DmRecipientPickerSourceTest`에 `recipient_content_container` 안에서 `rv_recipients`와 `tv_search_empty`가 전환되는 구조 검증을 추가했다.
- RED: `ChatMainFragmentLayoutTest`에 `onViewCreated()`에서 `shouldRefreshChatRoomsOnResume = false`가 초기화되는지 검증을 추가했고, 기존 구현에서 실패를 확인했다.
- GREEN: `activity_dm_recipient_picker.xml`에 콘텐츠 `FrameLayout`을 추가하고 empty/list를 같은 영역에서 전환하도록 바꿨다.
- GREEN: `DmRecipientPickerActivity`가 empty 상태에서 `rvRecipients`를 숨기도록 연결했다.
- GREEN: `ChatMainFragment.onViewCreated()`에서 resume guard를 초기화해 같은 Fragment 인스턴스의 view 재생성 직후 첫 resume 중복 갱신을 막았다.
- REFACTOR: `DmRecipientPickerSourceTest`의 toolbar/row/profile 치수 문자열 assertion을 제거하고 구조·상태 contract 검증만 남겼다.
- 검증:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"` 최초 RED 실패 1건 확인 후 GREEN 성공.
- 변경 Kotlin 파일 pure LOC: `ChatMainFragment.kt` 246, `DmRecipientPickerActivity.kt` 94, `DmRecipientPickerSourceTest.kt` 115, `ChatMainFragmentLayoutTest.kt` 230.
- 검증 보완:
- `ChatMainFragment.kt`와 `ChatMainFragmentLayoutTest.kt`는 200~250 경고 구간이다. 이번 변경은 guard 초기화와 회귀 assertion 최소 추가이며, 다음 확장 시 분리 검토가 필요하다.
- 기기·에뮬레이터 조작, screenshot, visual QA, `androidTest`는 사용자 명시 요청이 없어 저장소 지침대로 실행하지 않았다.
- 남은 항목: Phase 4 Gate 재실행 결과를 Verification Log에 누적한다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| `2026-09-14` | DEC-001 | 확정 | 이번 세션에서는 문서만 생성하고 코드 구현은 하지 않는다. | 사용자 요청 | 전체 |
| `2026-09-14` | DEC-002 | 확정 | 메인 대화 탭의 `+` 버튼과 신규 DM 시작 화면은 크리에이터에게만 노출한다. | 사용자 인터뷰 답변 | `P2-T3`, `prd.md` |
| `2026-09-14` | DEC-003 | 확정 | 검색 API는 기존 `GET /member/search`와 `nickname` 파라미터를 사용한다. | 사용자 요청과 기존 코드 확인 | `P2-T2`, `prd.md` |
| `2026-09-14` | DEC-004 | 정정 | 서버 계약 확인 전에는 `CreateDmChatRoomRequest.creatorId`를 `recipientId`로 변경하는 방향으로 계획했다. | 사용자 요청 | `P2-T1`, `EXT-001` |
| `2026-09-14` | DEC-005 | 확정 | 검색 debounce 대기 시간은 500ms로 한다. | 사용자 인터뷰 답변 | `P2-T2`, `prd.md` |
| `2026-09-14` | DEC-006 | 확정 | 검색어가 0~1자로 줄어들면 팔로우 리스트로 복귀한다. | 사용자 인터뷰 답변 | `P2-T2`, `prd.md` |
| `2026-09-14` | DEC-007 | 확정 | `/member/search` API 결과는 앱에서 추가 필터 없이 모두 선택 가능하게 둔다. | 사용자 인터뷰 답변 | `P2-T2`, `P2-T3`, `prd.md` |
| `2026-09-14` | DEC-008 | 확정 | Figma 한국어 문구를 최종 문구로 확정하고 기존 지원 언어 리소스에 번역을 추가한다. | 사용자 인터뷰 답변 | `P2-T3`, `prd.md` |
| `2026-09-14` | DEC-009 | 확정 | 레거시 수정이 필요하면 기능에 필요한 최소 수정은 허용한다. | 사용자 인터뷰 답변 | 전체 |
| `2026-09-14` | DEC-010 | 확정 | 서버가 `recipientId`와 기존 `creatorId`를 모두 수용하는 호환 계약을 제공했다. 신규 크리에이터→리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId`를 사용한다. | 서버 변경 공유 | `P2-T1`, `prd.md` |
| `2026-09-14` | DEC-011 | 확정 | DM 방에서 뒤로 돌아와 대화 탭이 다시 보이면 현재 filter의 첫 페이지를 재조회한다. page 0은 cursor 기반 API의 `cursor = null`로 처리한다. | 사용자 확인 및 추가 요청 | `P3-T1`, `prd.md` |
| `2026-09-14` | DEC-012 | 확정 | 수신자 선택 화면은 Figma `2372:23496`, 검색 결과 화면은 Figma `2372:23506`과 동일한 검색 toolbar와 `검색결과 N` 라벨을 사용한다. | 사용자 지적 및 Figma 재확인 | `P4-T1`, `prd.md` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| ISSUE-001 | High | 해결 | 서버가 기존 DM 생성 endpoint에서 `recipientId`와 기존 `creatorId`를 모두 수용하는 호환 계약을 제공했다. | `P2-T1`, `P2-T3` | 신규 리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId`로 구현 |
| ISSUE-002 | Medium | 해결 | 기존 검색 ViewModel에는 2글자 조건은 있으나 debounce 구현 여부는 확인되지 않았다. `OQ-001`에서 신규 기능 debounce는 500ms로 확정했다. | `P2-T2` | 500ms debounce 기준으로 신규 검색 로직 구현 |
| ISSUE-003 | Medium | 해결 | `ChatMainFragment`는 최초 진입에서만 `loadFirstPage()`를 호출하고, `DmChatRoomActivity`에서 뒤로 돌아오는 resume 시점의 대화 목록 재조회 wiring이 없다. | `P3-T1`, `P4-R1` | first-resume guard 후 subsequent resume에서 목록 clear와 첫 페이지 재조회 추가, view 재생성 시 guard 초기화 보완 |
| ISSUE-004 | Medium | 해결 | 수신자 선택 화면이 Figma와 달리 별도 `detail_toolbar` 아래 검색창을 쓰고, 검색 모드에서 0건 count인 `검색결과 0`을 표시하지 않는다. | `P4-T1`, `P4-R1` | Figma형 search toolbar와 검색 결과 count state/binding 추가, empty/list 콘텐츠 영역 전환 보완 |
## 최종 보고 형식
```markdown
구현 결과: 완료한 Phase와 사용자 흐름을 한 문장으로 작성한다.
- 변경: 주요 파일과 동작을 적는다.
- 결정: 중요한 Decision Log ID와 내용을 적는다.
- 검증:
- 실행 명령 — 성공/실패와 핵심 수치를 적는다.
- 수동 검증 — 성공/실패/불가 사유를 적는다.
- 남은 항목: 외부 의존, 후속 범위 또는 없음을 적는다.
- 문서: 갱신한 PRD/API Contract/plan/review 링크를 적는다.
```
## Verification Log
### 문서 생성 검증 — 2026-09-14
- 상태: 완료
- 무엇을: `prd.md`와 `plan-task.md`가 요청 범위, Figma 노드, 기존 API 근거, 사용자 인터뷰 결과를 포함하는지 점검했다.
- 왜: 코드 구현 없이 문서만 생성한다는 사용자 요청을 충족하기 위해서다.
- 어떻게:
- 요구사항 `DM-001`~`DM-019`와 계획 Task `P1-T1`, `P2-T1`~`P2-T3` 연결을 확인했다.
- `OQ-001`~`OQ-005`가 확정됐고, 이후 `EXT-001` 서버 계약도 해결 상태로 갱신됐는지 확인했다.
- Android local unit test만 계획하고 `androidTest`, 기기 조작, 스크린샷 QA를 제외했는지 확인했다.
- 남은 항목: 실제 구현 전 Phase 1의 계약·파일 지도 확인.
### Phase 2 Gate 검증 — 2026-09-14
- 상태: 완료
- 무엇을: 크리에이터의 리스너 DM 시작 흐름과 기존 DM/Chat 회귀를 최종 검증했다.
- 왜: `P2-T1`~`P2-T3`가 문서 요구사항과 구현 완료 조건을 모두 충족하는지 확인하기 위해서다.
- 어떻게:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" :app:assembleDebug :app:ktlintCheck`를 실행해 성공을 확인했다.
- `git diff --check`를 실행해 whitespace 오류가 없음을 확인했다.
- `./gradlew :app:testDebugUnitTest --tests "*Dm*" --tests "*Chat*" :app:assembleDebug :app:ktlintCheck`를 실행해 DM/Chat 영향 범위 회귀를 재확인했다.
- 결과:
- 최종 Gradle 명령: `BUILD SUCCESSFUL`, exit 0.
- `testDebugUnitTest`, `assembleDebug`, `ktlintCheck` 통과.
- `git diff --check` 출력 없음.
- 수동/제외 검증:
- 사용자 지시에 따라 `app/src/androidTest`, 기기·에뮬레이터 조작, screenshot, visual QA는 실행하지 않았다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
### Phase 6 아이콘 변경 검증 — 2026-09-17
- 상태: 완료
- 무엇을: 메인 대화 탭 우측 하단 `btn_new_dm` 아이콘 참조만 `@drawable/ic_new_talk_add`로 변경했는지 확인했다.
- 왜: 신규 PNG 리소스를 크리에이터 전용 `+` 버튼 아이콘으로 사용하고, 버튼의 다른 기능은 유지해야 하기 때문이다.
- 어떻게:
- `fragment_v2_main_chat.xml`의 `btn_new_dm` `android:src`를 `@drawable/ic_plus_no_bg`에서 `@drawable/ic_new_talk_add`로 변경했다.
- `./gradlew :app:mergeDebugResources --max-workers=1` 성공, `BUILD SUCCESSFUL`.
- `git diff --check` 출력 없음.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --max-workers=1`은 `ChatMainFragmentLayoutTest.kt:71`의 기존 title bar source assertion에서 실패했다. 이번 변경 파일인 `fragment_v2_main_chat.xml`의 아이콘 참조와는 무관한 `llTitleBarActions.addView` source contract 실패다.
- 수동/제외 검증:
- 아이콘만 교체하는 리소스 참조 변경이므로 신규 local unit test는 작성하지 않았다.
- 사용자 지시에 따라 기기·에뮬레이터 조작, screenshot, visual QA는 실행하지 않았다.
- 남은 항목: 없음.
### Phase 3 Gate 검증 — 2026-09-14
- 상태: 완료
- 무엇을: DM 방 복귀 시 대화 탭 목록 첫 페이지 갱신과 기존 채팅 회귀를 검증했다.
- 왜: `P3-T1`이 `DM-020`의 page 0 초기화, 기존 아이템 clear, 현재 filter 재조회 요구를 충족하는지 확인하기 위해서다.
- 어떻게:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`를 실행해 성공을 확인했다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*" --tests "kr.co.vividnext.sodalive.v2.chat.action.*" :app:assembleDebug :app:ktlintCheck`를 실행해 성공을 확인했다.
- `git diff --check`를 실행해 whitespace 오류가 없음을 확인했다.
- 결과:
- 최종 Gradle 명령: `BUILD SUCCESSFUL`, exit 0.
- `testDebugUnitTest`, `assembleDebug`, `ktlintCheck` 통과.
- `git diff --check` 출력 없음.
- 수동/제외 검증:
- 사용자 확인으로 뒤로가기 시 대화 탭으로 복귀하는 흐름은 기능적으로 맞다고 확정했다.
- 사용자 지시에 따라 `app/src/androidTest`, 기기·에뮬레이터 조작, screenshot, visual QA는 실행하지 않았다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
### Phase 4 Gate 검증 — 2026-09-14
- 상태: 완료
- 무엇을: Figma 수신자 검색 UI 정합성과 기존 수신자/DM 생성 회귀를 검증했다.
- 왜: `P4-T1`이 `DM-021`, `DEC-012`, `ISSUE-004`의 검색 toolbar와 `검색결과 0` 표시 요구를 충족하는지 확인하기 위해서다.
- 어떻게:
- focused test로 layout/source contract와 ViewModel count state를 확인했다.
- recipient 영향 범위와 기존 `DmChatRepositoryTest`, `assembleDebug`, `ktlintCheck`를 한 번에 실행했다.
- `git diff --check`로 whitespace 오류가 없음을 확인했다.
- 결과:
- 최종 Gradle 명령: `BUILD SUCCESSFUL`, exit 0.
- `testDebugUnitTest`, `assembleDebug`, `ktlintCheck` 통과.
- `git diff --check` 출력 없음.
- 수동/제외 검증:
- 사용자 지시에 따라 `app/src/androidTest`, 기기·에뮬레이터 조작, screenshot, visual QA는 실행하지 않았다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
### Phase 5 Gate 검증 — 2026-09-14
- 상태: 완료
- 무엇을: 팔로워 0명 라벨 숨김, `팔로워`/`검색결과 0` 라벨 문구 크기 통일, 검색결과 숫자 `gray_500` span 적용을 검증했다.
- 왜: `P5-T1`이 `DM-022`, `DM-023`의 후속 UI 요구를 충족하는지 확인하기 위해서다.
- 어떻게:
- RED focused test로 기존 구현이 팔로워 모드만 보고 라벨을 표시하고, 검색결과 라벨 style과 숫자 색상 span이 요구와 다름을 확인했다.
- GREEN focused test로 `DmRecipientPickerSourceTest` 통과를 확인했다.
- recipient 영향 범위와 기존 `DmChatRepositoryTest`, `assembleDebug`, `ktlintCheck`를 한 번에 실행했다.
- `git diff --check`로 whitespace 오류가 없음을 확인했다.
- 결과:
- 최종 Gradle 명령: `BUILD SUCCESSFUL`, exit 0.
- `testDebugUnitTest`, `assembleDebug`, `ktlintCheck` 통과.
- `git diff --check` 출력 없음.
- 수동/제외 검증:
- 사용자 지시에 따라 `app/src/androidTest`, 기기·에뮬레이터 조작, screenshot, visual QA는 실행하지 않았다.
- LSP diagnostics 도구는 제공되지 않아 Kotlin 컴파일과 ktlint로 대체했다.
- 남은 항목: 없음.
@@ -0,0 +1,281 @@
# 크리에이터의 리스너 DM 시작 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | `2026-09-14` |
| 최종 수정일 | `2026-09-14` |
| 대상 제품 | 크리에이터의 리스너 DM 시작 |
| 작성자·결정권자 | 제품 담당자, Android 담당자 |
| 관련 API Contract | 서버 `CreateUserCreatorChatRoomRequest(recipientId?, creatorId?)` 계약 제공됨 |
| 관련 구현 계획 | `docs/20260914_크리에이터의_리스너_DM_시작/plan-task.md` |
| 관련 review | `docs/20260914_크리에이터의_리스너_DM_시작/reviews/phase4-recipient-picker-review.md` |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 미결 | 제품·UX·운영 결정이 더 필요함 | 구현 전 확인하고 Decision Log에 누적 |
| 외부 의존 | 서버 계약·권한·배포 조건 확인이 필요함 | 담당 주체·영향·재개 조건 기록 |
| 제외 | 현재 릴리스에서 구현하지 않음 | 제외 이유와 후속 조건 기록 |
## 1. Overview
현재 리스너는 크리에이터 채널에서 크리에이터에게 바로 DM을 보낼 수 있지만, 크리에이터가 리스너에게 먼저 DM을 시작하는 진입점은 없다. 이 기능은 크리에이터가 메인 대화 탭에서 리스너를 찾고, 확인 팝업을 거쳐 기존 DM 방 생성 흐름으로 대화를 시작할 수 있게 한다.
현재 요구사항은 Phase 4까지 구현과 회귀 검증이 완료됐다. 이후 변경은 이 문서와 `plan-task.md`의 추가 Task로 추적한다.
## 2. Problem Statement
- 크리에이터가 리스너에게 먼저 연락해야 하는 상황에서도 현재 앱에는 직접 DM 시작 경로가 없다.
- 기존 DM 생성 API와 대화방 이동 흐름은 있으나, 메인 대화 탭에서 수신자를 선택하는 화면과 검색 흐름이 없다.
- 검색 호출 조건과 빈 결과 문구, 확인 팝업 문구가 다국어 리소스로 정리되어야 한다.
문제를 해결했다는 판단은 크리에이터가 메인 대화 탭의 `+` 버튼에서 리스너를 선택하고, `보내기`로 기존 DM 방 생성 API를 호출한 뒤 DM 방으로 이동하는 흐름이 검증되는 것으로 한다.
## 3. Goals
### 3.1 제품 목표
- 크리에이터가 리스너에게 먼저 DM을 시작할 수 있다.
- 기존 크리에이터 채널의 DM 생성 API와 방 이동 흐름을 재사용한다.
- 검색 API 호출은 입력 2글자 이상과 debounce 조건을 만족할 때만 발생한다.
### 3.2 UX 목표
- 메인 대화 탭 우측 하단에 홈 화면과 유사한 `+` 버튼을 제공한다.
- 신규 수신자 선택 화면은 Figma `2372:23496`의 검색바, 팔로우 섹션, 사용자 리스트 구조를 따른다.
- 사용자 선택 시 Figma `2372:23521`의 확인 팝업 구조를 따르고, v2 내부 공통 다이얼로그를 재사용한다.
- 사용자에게 보이는 모든 신규 문구는 다국어 리소스로 관리한다.
## 4. Non-Goals
- 신규 백엔드 endpoint를 추가하지 않는다.
- DM 메시지 송수신 화면 자체의 UI, 소켓, 음성 메시지 전송 정책은 변경하지 않는다.
- 팔로우/검색 결과의 프로필 상세, 차단, 팔로우 토글 정책은 이번 범위에서 새로 정의하지 않는다.
- 레거시 파일 변경이 필요하면 구현 전에 별도 확인한다.
## 5. Target Users and Permissions
### 5.1 사용자
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| 크리에이터 | 리스너에게 먼저 DM을 시작 | 대화 탭 진입, 수신자 검색/선택, DM 방 생성 | Android 앱 |
| 리스너 | 크리에이터로부터 시작된 DM을 수신 | 기존 DM 방에서 대화 | Android 앱 |
### 5.2 권한
- 인증 주체: 로그인한 사용자.
- 허용 역할: 크리에이터 역할 사용자.
- UI 노출 조건: 메인 대화 탭의 `+` 버튼과 신규 수신자 선택 화면은 크리에이터에게만 노출한다.
- 서버 권한: 기존 DM 생성 API가 `recipientId`와 `creatorId`를 모두 수용하는 호환 계약으로 제공됐다.
## 6. 핵심 사용자 흐름
1. 크리에이터가 메인 대화 탭에 진입한다.
2. 우측 하단 `+` 버튼을 터치한다.
3. 신규 수신자 선택 화면이 열리고, 최초 상태에서는 팔로우 리스트를 보여준다.
4. 검색어를 입력하면 2글자 이상이며 debounce가 끝난 뒤 `GET /member/search`를 호출한다.
5. 검색 결과가 없으면 화면 가운데에 `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`를 표시한다.
6. 사용자 리스트에서 사용자를 터치하면 확인 팝업을 표시한다.
7. `취소`를 터치하면 팝업만 닫고 API를 호출하지 않는다.
8. `보내기`를 터치하면 기존 크리에이터 채널 DM 생성과 같은 API를 호출하고 DM 방으로 이동한다.
## 7. 정보 구조와 라우팅
```text
MainV2Activity
ChatMainFragment
+ button (creator only)
DmRecipientPickerActivity (v2 package)
Follow list state
Search result state
Empty state
Confirm dialog
DmChatRoomActivity
```
- 신규 화면은 `kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity`로 구현한다.
- 신규 화면과 연결 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 둔다.
- 기존 채팅 방 이동은 `DmChatRoomActivity`의 기존 진입 방식을 재사용한다.
## 8. 기능 요구사항
### 8.1 진입점과 권한
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| DM-001 | 확정 | 크리에이터는 메인 대화 탭에서 리스너에게 먼저 DM을 시작할 수 있다. | 크리에이터 계정에서 신규 수신자 선택 화면에 진입할 수 있다. | `P2-T3` |
| DM-002 | 확정 | 메인 대화 탭 우측 하단에 `ic_new_talk_add` 아이콘을 사용하는 `+` 버튼을 추가한다. | 버튼은 크리에이터 역할 사용자에게만 보이고, 비크리에이터에게는 보이지 않는다. | `P1-T1`, `P2-T3`, `P6-T1` |
### 8.2 수신자 선택 화면
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| DM-003 | 확정 | 신규 화면은 Figma `2372:23496`을 기준으로 검색바, 팔로우 섹션, 사용자 목록을 제공한다. | 검색바 placeholder, 팔로우 섹션, 리스트 구조가 문서화되고 구현 전 기존 UI 패턴과 연결된다. | `P1-T1`, `P2-T3` |
| DM-004 | 확정 | 화면 최초 진입 시 팔로우 리스트를 보여준다. | 기존 팔로우 리스트 페이지와 동일한 API를 호출한다. | `P2-T2` |
| DM-005 | 확정 | 검색어가 0~1자로 줄어든 경우 검색 모드를 해제하고 팔로우 리스트로 복귀한다. | 0~1글자 입력에서는 `/member/search` 요청이 발생하지 않고 팔로우 리스트 상태가 표시된다. | `P2-T2` |
### 8.3 검색
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| DM-006 | 확정 | 검색은 2글자 이상 입력했을 때 유저 검색 API를 호출한다. | 0~1글자 입력에서는 `/member/search` 요청이 발생하지 않는다. | `P2-T2` |
| DM-007 | 확정 | 검색 API는 `GET /member/search`를 사용하고 파라미터는 `nickname`이다. | 요청 query가 `nickname=입력값`으로 전달된다. | `P2-T2` |
| DM-008 | 확정 | debounce를 적용해 글자를 치는 중에는 API를 호출하지 않는다. | 연속 입력 중 요청 0회, 입력 중지 후 최종 검색어 요청 1회가 검증된다. | `P2-T2` |
| DM-009 | 확정 | debounce 대기 시간은 500ms로 한다. | 입력 중지 후 500ms가 지난 최종 검색어로만 `/member/search`를 1회 호출한다. | `P2-T2` |
### 8.4 Empty와 다국어
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| DM-010 | 확정 | 검색 결과가 없을 때 가운데 안내 문구를 표시한다. | `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`가 중앙 empty state로 표시된다. | `P2-T2`, `P2-T3` |
| DM-011 | 확정 | 모든 신규 문구는 다국어 리소스로 처리한다. | 한국어, 일본어 등 기존 지원 언어 리소스에 신규 key가 추가된다. | `P2-T3` |
| DM-012 | 확정 | 검색바 placeholder는 `팬 이름을 입력하세요`, 섹션 제목은 `팔로워`로 한다. | Figma 기준 한국어 문구를 기존 지원 언어 리소스에 번역해 추가한다. | `P2-T3` |
### 8.5 확인 팝업과 DM 생성
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| DM-013 | 확정 | 사용자 터치 시 Figma `2372:23521` 기준 확인 팝업을 표시한다. | 제목, 본문, 버튼이 지정 문구로 노출된다. | `P2-T3` |
| DM-014 | 확정 | 팝업 다이얼로그는 v2 내부에서 사용하는 공통 다이얼로그를 재사용하고 문구만 변경한다. | 신규 다이얼로그 구현 없이 기존 공통 다이얼로그 contract를 소비한다. | `P1-T1`, `P2-T3` |
| DM-015 | 확정 | 팝업 제목은 `메시지 보내기`다. | 신규 string resource로 관리된다. | `P2-T3` |
| DM-016 | 확정 | 팝업 본문은 `{{사용자이름}}에게 메시지를 보낼까요?`다. | 사용자 이름을 포맷 인자로 전달한다. | `P2-T3` |
| DM-017 | 확정 | 팝업 버튼은 `취소`, `보내기`다. | `취소`는 요청 없이 닫고, `보내기`는 DM 생성 요청을 1회 호출한다. | `P2-T3` |
| DM-018 | 확정 | `보내기`는 크리에이터 채널에서 DM 보내기를 터치했을 때 호출되는 API와 동일한 API를 호출한다. | 기존 DM 생성 endpoint와 방 이동 흐름을 재사용한다. | `P2-T1`, `P2-T3` |
| DM-019 | 확정 | DM 생성 요청 DTO는 `recipientId`와 `creatorId`를 모두 표현할 수 있어야 한다. | 신규 크리에이터→리스너 DM은 `recipientId`만 보내고, 기존 크리에이터 채널 DM은 `creatorId`를 유지한다. | `P2-T1` |
| DM-020 | 확정 | DM 방에서 뒤로 돌아와 대화 탭이 다시 보이면 대화방 목록을 첫 페이지부터 갱신한다. | 기존 표시 목록을 비운 뒤 현재 선택 filter로 첫 페이지를 다시 요청하고, 요청 cursor는 `null`이다. | `P3-T1` |
| DM-021 | 확정 | 수신자 선택 화면은 Figma `2372:23496`, 검색 결과 화면은 Figma `2372:23506`과 동일한 상단 검색 toolbar, 섹션/검색결과 라벨, 리스트 행 구조를 사용한다. | 별도 `detail_toolbar`를 쓰지 않고 뒤로가기와 검색 input이 같은 54dp 검색 toolbar 안에 배치되며, 검색 모드에서는 0건도 `검색결과 0`을 표시한다. | `P4-T1` |
| DM-022 | 확정 | 수신자 선택 화면의 팔로워 목록이 0명이면 `팔로워` 섹션 문구를 표시하지 않는다. | 팔로워 모드라도 표시할 수신자가 0명이면 `팔로워` 라벨이 숨겨진다. | `P5-T1` |
| DM-023 | 확정 | `검색결과 0`과 `팔로워` 문구 크기를 통일하고, 검색결과 숫자만 `gray/500`으로 한다. | 두 라벨은 같은 typography style을 사용하고, 검색결과 라벨의 숫자 범위에만 `@color/gray_500` span을 적용한다. | `P5-T1` |
## 9. UI/UX Expectations
### 9.1 Figma 근거
| 항목 | Figma node | 확인 내용 |
|---|---|---|
| 수신자 선택 화면 | `2372:23496` | 상단 status bar, 뒤로가기, 검색바, `팔로워` 섹션, 사용자 리스트 |
| 수신자 검색 결과 화면 | `2372:23506` | 뒤로가기와 검색 input이 같은 검색 toolbar, `검색결과 N` sort-bar, 검색 결과 리스트 |
| 확인 팝업 | `2372:23521` | 제목 `메시지 보내기`, 본문 `{{사용자이름}}에게 메시지를 보낼까요?`, 버튼 `취소`/`보내기` |
### 9.2 화면 상태
- 최초 로딩: 기존 팔로우 목록 API 로딩 정책을 따른다.
- 팔로우 목록 empty: 기존 팔로우 목록 페이지 정책을 확인 후 재사용한다.
- 팔로우 목록 count: 팔로워가 0명이면 `팔로워` 섹션 문구를 숨긴다.
- 검색 결과 empty: `DM-010` 문구를 중앙에 표시한다.
- 검색 결과 count: 검색 결과가 0건이어도 Figma `2372:23506` 위치에 `검색결과 0`을 표시한다.
- 검색 결과 count style: `팔로워` 라벨과 같은 문구 크기를 사용하고 숫자만 `gray/500` 색상으로 표시한다.
- 검색 오류: 기존 검색/목록 오류 toast 또는 empty 정책을 확인 후 최소 변경으로 맞춘다.
- 보내기 진행 중: 중복 터치를 막고 기존 로딩/비활성화 패턴을 따른다.
## 10. API 계약
### 10.1 알려진 기존 코드 근거
| 목적 | 기존 근거 | 확인된 내용 |
|---|---|---|
| 채팅 탭 | `ChatMainFragment` | 메인 대화 탭, 필터, 채팅방 목록 진입점 |
| DM 생성 API | `DmChatApi.createDmChatRoom` | `POST /api/v2/user-creator-chat/rooms/create` |
| DM 요청 생성 | `DmChatRepository.createOrGetRoom(token, creatorId)` | 현재 기존 크리에이터 채널 DM 경로에서 `CreateDmChatRoomRequest(creatorId = creatorId)` 생성 |
| 기존 크리에이터 DM 이동 | `ChatActionCommand.DmCreator`, `DmChatRoomActivity.newIntentByCreatorId` | 크리에이터 채널에서 기존 DM 방 진입에 사용 |
| 유저 검색 | `UserApi.searchUser`, `UserRepository.searchUser` | `GET /member/search`, query `nickname` |
| 검색 참고 구현 | `SelectMessageRecipientViewModel.searchUser` | `nickname.length > 1` 조건은 있으나 debounce는 별도 확인 필요 |
| 팔로우 목록 | `FollowingCreatorRepository.getFollowedCreatorAllList` | 기존 팔로우 목록 API 재사용 후보 |
### 10.2 서버 제공 계약
| ID | 상태 | 제공 계약 | 담당 주체 | 구현 영향 | 구현 기준 |
|---|---:|---|---|---|---|
| EXT-001 | 해결 | `CreateUserCreatorChatRoomRequest(recipientId: Long? = null, creatorId: Long? = null)` 제공. 서버는 둘 다 있으면 같은 값이어야 하며, `recipientId ?: creatorId`를 수신자 member ID로 사용한다. | 서버/API 담당자 | 신규 리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId`를 사용할 수 있다. | 클라이언트 DTO는 두 필드를 모두 nullable로 표현하고 호출 경로별로 하나만 채운다. |
## 11. 보안과 데이터 취급
- 인증 header는 기존 repository 정책을 재사용한다.
- token, Authorization header, 내부 사용자 식별자는 log, Toast, 문서 fixture에 직접 노출하지 않는다.
- 검색어와 사용자 이름은 화면 표시와 API 요청에만 사용하고 별도 저장하지 않는다.
- `/member/search` API가 반환한 사용자는 앱에서 추가 필터 없이 모두 선택 가능하게 둔다.
- DM 방 복귀 갱신은 기존 채팅 목록 첫 페이지 API를 재사용하고 token, 내부 식별자를 log·Toast에 노출하지 않는다.
## 12. 성능과 품질 요구사항
- 검색 API는 2글자 이상과 500ms debounce 완료 조건을 모두 만족할 때만 호출한다.
- 연속 입력 중 중간 검색어 요청을 보내지 않는다.
- 검색어가 0~1자로 줄어들면 검색 모드를 해제하고 팔로우 리스트로 복귀한다.
- 동일 화면 내 팔로우 목록과 검색 결과 상태 전환이 명확해야 한다.
- DM 방 복귀 후 대화 탭 목록 갱신은 기존 pagination 상태를 첫 페이지 기준으로 초기화한다. 이 저장소의 v2 대화 목록은 cursor 기반이므로 page 0은 `cursor = null` 요청을 의미한다.
- 수신자 선택 화면의 Figma 정합성은 Android XML 구조/source test와 resource compile로 검증하고, View 크기·constraint 속성 자체를 unit/UI test에서 직접 검증하지 않는다.
- 신규 테스트는 `app/src/test`의 로직/local unit test만 계획한다.
- `app/src/androidTest`, `connectedDebugAndroidTest`, 기기·에뮬레이터 UI 조작, 스크린샷·시각 QA는 사용자가 별도로 요청하기 전까지 범위에서 제외한다.
## 13. 성공 기준
### 13.1 기능 수용 기준
- [x] 크리에이터에게만 메인 대화 탭 `+` 버튼이 보인다. (`DM-001`, `DM-002`)
- [x] 신규 화면 최초 진입 시 기존 팔로우 리스트 API를 호출한다. (`DM-004`)
- [x] 검색은 2글자 이상과 debounce 완료 후에만 `/member/search?nickname=`을 호출한다. (`DM-006`~`DM-009`)
- [x] 검색 결과가 없으면 지정 empty 문구를 중앙에 표시한다. (`DM-010`)
- [x] 사용자 선택 후 팝업에서 `보내기`를 터치하면 `recipientId`로 기존 DM 생성 API를 호출하고, 기존 크리에이터 채널 DM은 `creatorId`로 유지된다. (`DM-013`~`DM-019`)
- [x] DM 방에서 뒤로 돌아오면 대화 탭의 기존 목록이 비워지고 현재 filter의 첫 페이지가 다시 표시된다. (`DM-020`)
- [x] 수신자 선택 화면 상단이 Figma 검색 toolbar 구조와 일치하고, 검색 결과가 0건이어도 `검색결과 0`이 표시된다. (`DM-021`)
- [x] 팔로워가 0명이면 수신자 선택 화면의 `팔로워` 라벨이 표시되지 않는다. (`DM-022`)
- [x] `검색결과 0`과 `팔로워` 라벨의 문구 크기가 같고 검색결과 숫자만 `gray/500` 색상이다. (`DM-023`)
### 13.2 추적성 완료 기준
- [x] 모든 확정 요구사항이 `plan-task.md`의 Phase/Task에 연결된다.
- [x] 서버 제공 계약 `EXT-001`이 `DM-019`와 `plan-task.md`의 `P2-T1` 구현 기준에 반영돼 있다.
## 14. Resolved Questions
| ID | 상태 | 결정 사항 | 근거 | 결정 주체 | 결정일 | 영향 Goal |
|---|---|---|---|---|---|---|
| OQ-001 | 확정 | debounce 대기 시간은 500ms로 한다. | 사용자 인터뷰 답변 | 제품/Android 담당자 | `2026-09-14` | `P2-T2` |
| OQ-002 | 확정 | 검색어가 0~1자로 줄어들면 팔로우 리스트로 복귀한다. | 사용자 인터뷰 답변 | 제품 담당자 | `2026-09-14` | `P2-T2` |
| OQ-003 | 확정 | `/member/search` API 결과는 앱에서 추가 필터 없이 모두 선택 가능하게 둔다. | 사용자 인터뷰 답변 | 제품/서버 담당자 | `2026-09-14` | `P2-T2`, `P2-T3` |
| OQ-004 | 확정 | Figma 한국어 문구 `팬 이름을 입력하세요`, `팔로워`, `사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.`를 최종 문구로 한다. | 사용자 인터뷰 답변 | 제품/번역 담당자 | `2026-09-14` | `P2-T3` |
| OQ-005 | 확정 | 레거시 수정이 필요하면 기능에 필요한 최소 수정은 허용한다. | 사용자 인터뷰 답변 | 사용자/Android 담당자 | `2026-09-14` | 전체 |
## 15. 요구사항 추적표
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---:|---|---|---|
| `DM-001~002` | 2 | `P2-T3` | 역할별 진입 판단 local unit test | 코드 구현 단계에서 앱 흐름 확인 |
| `DM-003~005` | 1, 2 | `P1-T1`, `P2-T2`, `P2-T3` | 상태 mapper/ViewModel test | 코드 구현 단계에서 화면 확인 |
| `DM-006~009` | 2 | `P2-T2` | 검색 길이/debounce/request test | 없음 |
| `DM-010~012` | 2 | `P2-T2`, `P2-T3` | empty state/resource mapping test | 코드 구현 단계에서 문구 확인 |
| `DM-013~019` | 2 | `P2-T1`, `P2-T3` | request serialization, dialog action, navigation test | 코드 구현 단계에서 팝업 확인 |
| `DM-020` | 3 | `P3-T1` | Fragment source wiring test, ViewModel first-page reset regression test | 사용자가 확인한 뒤로가기 흐름 |
| `DM-021` | 4 | `P4-T1` | 수신자 layout/source test, 검색 결과 count ViewModel test | Figma screenshot 대조 |
| `DM-022` | 5 | `P5-T1` | 팔로워 라벨 표시 조건 source test | 없음 |
| `DM-023` | 5 | `P5-T1` | 수신자 layout typography와 숫자 color span source test | 없음 |
## 16. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| `2026-09-14` | DEC-001 | 확정 | 이번 세션에서는 문서만 생성하고 코드 구현은 하지 않는다. | 사용자 요청 | 전체 |
| `2026-09-14` | DEC-002 | 확정 | 메인 대화 탭의 `+` 버튼과 신규 DM 시작 화면은 크리에이터에게만 노출한다. | 사용자 인터뷰 답변 | `DM-001`, `DM-002`, `P2-T3` |
| `2026-09-14` | DEC-003 | 확정 | 검색 API는 `GET /member/search`와 `nickname` 파라미터를 사용한다. | 사용자 요청과 기존 `UserApi.searchUser` 확인 | `DM-006`~`DM-008`, `P2-T2` |
| `2026-09-14` | DEC-004 | 정정 | 서버 계약 확인 전에는 `CreateDmChatRoomRequest.creatorId`를 `recipientId`로 변경하는 방향으로 계획했다. | 사용자 요청 | `DM-019`, `EXT-001`, `P2-T1` |
| `2026-09-14` | DEC-005 | 확정 | 검색 debounce 대기 시간은 500ms로 한다. | 사용자 인터뷰 답변 | `DM-008`, `DM-009`, `P2-T2` |
| `2026-09-14` | DEC-006 | 확정 | 검색어가 0~1자로 줄어들면 팔로우 리스트로 복귀한다. | 사용자 인터뷰 답변 | `DM-005`, `P2-T2` |
| `2026-09-14` | DEC-007 | 확정 | `/member/search` API 결과는 앱에서 추가 필터 없이 모두 선택 가능하게 둔다. | 사용자 인터뷰 답변 | `P2-T2`, `P2-T3` |
| `2026-09-14` | DEC-008 | 확정 | Figma 한국어 문구를 최종 문구로 확정하고 기존 지원 언어 리소스에 번역을 추가한다. | 사용자 인터뷰 답변 | `DM-010`~`DM-012`, `P2-T3` |
| `2026-09-14` | DEC-009 | 확정 | 레거시 수정이 필요하면 기능에 필요한 최소 수정은 허용한다. | 사용자 인터뷰 답변 | 전체 |
| `2026-09-14` | DEC-010 | 확정 | 서버가 `recipientId`와 기존 `creatorId`를 모두 수용하는 호환 계약을 제공했다. 신규 크리에이터→리스너 DM은 `recipientId`, 기존 크리에이터 채널 DM은 `creatorId`를 사용한다. | 서버 변경 공유 | `DM-019`, `EXT-001`, `P2-T1` |
| `2026-09-14` | DEC-011 | 확정 | DM 방에서 뒤로 돌아와 대화 탭이 다시 보이면 현재 filter의 첫 페이지를 재조회한다. page 0은 cursor 기반 API의 `cursor = null`로 처리한다. | 사용자 확인 및 추가 요청 | `DM-020`, `P3-T1` |
| `2026-09-14` | DEC-012 | 확정 | 수신자 선택 화면은 Figma `2372:23496`, 검색 결과 화면은 Figma `2372:23506`과 동일한 검색 toolbar와 `검색결과 N` 라벨을 사용한다. | 사용자 지적 및 Figma 재확인 | `DM-021`, `P4-T1` |
## 17. 변경 관리
- 요구사항 변경 시 Decision Log에 변경 이유와 날짜를 기록한다.
- 관련 요구사항 상태, 수용 기준, 계획 Task를 코드 변경 전에 갱신한다.
- 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않는다.
- 서버 계약이 다시 변경되면 구현 전에 `EXT-001` 상태를 갱신하고 사용자에게 확인한다.
@@ -0,0 +1,55 @@
# Phase 4 수신자 선택 UI 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / `P4-T1` |
| 기준 commit 또는 working tree | uncommitted working tree |
| 리뷰 일자 | `2026-09-14` |
| 리뷰어 | Oracle read-only review |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 수정 검증 완료 |
## 2. 리뷰 목적과 범위
- Figma 수신자 선택/검색 결과 UI 정합성과 기존 DM 흐름 회귀를 확인한다.
- 완료 체크박스, 실제 코드, test, 검증 기록이 일치하는지 확인한다.
포함 범위는 `DmRecipientPickerActivity`, `DmRecipientPickerViewModel`, 수신자 XML layout, recipient/chat source tests, PRD와 plan 문서다. 기기·에뮬레이터 UI 조작, screenshot, visual QA, `androidTest`는 저장소 지침에 따라 제외했다.
## 3. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P4-001` | Medium | 수정 완료 | 검색 empty 문구가 남은 콘텐츠 영역 중앙이 아니라 섹션 아래에 배치됨 | `P4-T1` | `P4-R1` |
| `REV-P4-002` | Medium | 수정 완료 | 레이아웃 치수 assertion이 저장소 테스트 정책과 충돌 | `P4-T1` | `P4-R1` |
| `REV-P4-003` | Medium | 수정 완료 | `ChatMainFragment` view 재생성 시 resume guard가 초기화되지 않음 | `P3-T1` | `P4-R1` |
| `REV-P4-004` | Low | 수정 완료 | PRD/계획 문서 상태가 구현 완료 상태와 불일치 | 문서 | `P4-R1` |
## 4. 수정 후 검증 기록
### 1차 수정 검증 — 2026-09-14
- 무엇을: `REV-P4-001`~`REV-P4-004`를 `P4-R1`로 전환해 수정했다.
- 왜: `DM-010`, `DM-020`, `DM-021`과 저장소 테스트 정책을 동시에 만족하기 위해서다.
- 어떻게:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"` — RED 실패 확인 후 GREEN 성공.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.recipient.*" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" :app:assembleDebug :app:ktlintCheck` — `BUILD SUCCESSFUL`.
- `git diff --check` — 출력 없음.
- 검색 empty/list를 `recipient_content_container` 안에서 전환하도록 수정했다.
- `ChatMainFragment.onViewCreated()`에서 resume guard를 초기화했다.
- 레이아웃 치수 직접 assertion을 제거하고 구조·상태 contract 검증만 유지했다.
- 남은 항목: 없음.
## 5. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Oracle read-only review 결과 반영 |
| 후보 항목 판정 완료 | 충족 | 4건 모두 `P4-R1`로 전환 |
| 확정 항목 plan 반영 | 충족 | `plan-task.md` Task 4.2 추가 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
| 검증 명령과 결과 기록 | 충족 | focused RED/GREEN, Phase 4 Gate 재실행, `git diff --check` 기록 |
**최종 결론:** 수정 검증 완료. 남은 항목 없음.
@@ -0,0 +1,184 @@
# 레거시 메인 직접 진입 V2 전환 구현 계획
> **For agentic workers:** 구현은 TDD 순서로 수행하고 각 검증 결과를 이 문서에 누적한다.
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 중 |
| 작성일 | 2026-09-18 |
| 요구사항 기준 | `docs/20260918_레거시_메인_직접_진입_v2_전환/prd.md` |
| API 기준 | 해당 없음 |
| 현재 Phase | Phase 3 예약 status package 경로 정합성 |
| 현재 활성 Goal | `P3-T1` |
## 목표
레거시 `MainActivity`를 직접 여는 두 사용자 흐름이 기존 부가 동작을 유지하면서 `MainV2Activity`로 진입한다.
## 범위
### 포함
- `LiveRoomActivity` 딥링크 confirm Intent target 교체
- `LiveReservationCancelActivity` 다른 수다 예약 Intent target 교체
- source regression test와 focused build 검증
### 제외
- 레거시 Fragment 및 `MainActivity` 내부 변경
- 라우팅 공통화 또는 리팩터링
- 기기·에뮬레이터 UI 테스트
## 기술적 제약
- Kotlin/Android 기존 Intent 패턴을 유지한다.
- 신규 의존성이나 추상화를 추가하지 않는다.
- `app/src/test` local unit source test만 추가한다.
## Phase 1
**Phase 결과:** 두 직접 진입 경로가 V2 메인을 사용한다.
### Task 1.1 직접 진입 target 전환
**Goal 실행 `P1-T1`:** 두 경로의 target만 `MainV2Activity`로 최소 교체한다.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/live/reservation/status/LiveReservationCancelActivity.kt`
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2DirectEntrySourceTest.kt`
- [x] **RED:** 두 production 파일의 V2 target과 레거시 target 부재를 검증하는 source test를 작성한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2DirectEntrySourceTest" --max-workers=1`이 기존 `MainActivity` target 때문에 실패하는지 확인한다.
- [x] **GREEN:** 두 파일의 import와 Intent target만 `MainV2Activity`로 교체한다.
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
- [x] **REFACTOR:** 추가 리팩터링 없이 관련 source test, compile, lint를 실행한다.
### 시나리오 계약
| 시나리오 | 통과 조건 | 검증 surface | Test ID |
|---|---|---|---|
| 라이브 푸시 confirm | `MainV2Activity` target이며 bundle과 두 flag가 유지된다. | source test | `라이브룸 딥링크 confirm은 MainV2Activity로 이동한다` |
| 예약 취소 다른 수다 예약 | `finishAffinity()` 후 `MainV2Activity`를 시작한다. | source test | `예약 취소의 다른 수다 예약은 MainV2Activity로 이동한다` |
| 인접 회귀 | 대상 두 파일에 레거시 `MainActivity` import/target이 없다. | source scan | 위 두 test의 부재 assertion |
### Phase 1 Gate
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2DirectEntrySourceTest" --max-workers=1
./gradlew :app:compileDebugKotlin --max-workers=1
./gradlew :app:ktlintCheck --max-workers=1
git diff --check
```
**Expected:** 모든 명령 exit code 0, 두 경로에서 `MainActivity::class.java` 직접 진입 0건.
## Phase 1 Verification Log
- 2026-09-18: 구현 전 계획 확정. 검증 결과는 Task 진행에 따라 누적한다.
- 2026-09-18: RED에서 `MainV2DirectEntrySourceTest` 2개가 각각 `MainV2Activity` import 부재 assertion으로 실패함을 확인했다.
- 2026-09-18: GREEN에서 같은 focused test 2개가 성공했다. 두 대상 파일의 레거시 `MainActivity` import/Intent 검색 결과는 0건이며 `git diff --check`도 성공했다.
- 2026-09-18: `:app:compileDebugKotlin`은 성공했다. 전체 `:app:testDebugUnitTest`는 1,263개 중 이번 변경과 무관한 `ChatMainFragmentLayoutTest:71`, `ChatMainTitleBarActionSourceTest:16` 2개가 실패했다.
- 2026-09-18: `:app:ktlintCheck`는 기존 `live.reservation_status` package underscore 규칙 위반으로 실패했다. 후속 요청에 따라 package 선언과 manifest/import 참조를 `live.reservation.status`로 변경했다.
- 2026-09-18: Oracle 리뷰 결과 차단 사항 없이 승인됐다. 호출 순서 assertion 보강은 비차단 참고 사항으로 남겼다.
- 2026-09-18: 후속 ktlint 수정 RED에서 `LiveReservationPackageSourceTest` 2개가 기존 `reservation_status` package/import/manifest 참조 때문에 실패했다. 이후 package 선언과 참조를 `live.reservation.status`로 교체했고, `LiveReservationPackageSourceTest`, `MainV2DirectEntrySourceTest`, `:app:compileDebugKotlin`, `:app:ktlintCheck`, production/manifest underscore package 참조 검색이 모두 성공했다.
## Phase 2
**Phase 결과:** foreground 라이브룸에서 다른 라이브룸으로 이동할 때 V2 메인이 로딩을 표시하며 1초 뒤 입장을 시작한다.
### Task 2.1 foreground 라이브룸 딥링크 지연 처리
**Goal 실행 `P2-T1`:** `SINGLE_TOP`을 유지하면서 대상 라이브룸 딥링크에만 기존 deferred loading 흐름을 적용한다.
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- [x] **RED:** foreground live route 선택적 지연, loading 인계, 종료 callback 제거를 검증하는 source test를 추가한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`이 새 동작 부재로 실패하는지 확인한다.
- [x] **GREEN:** 기존 `routeLoadingDialog`, `isDeferredRouteLoading`, `isLiveEntryLoading`을 재사용해 `onNewIntent()` 대상 경로만 1초 지연하고 종료 시 callback을 제거한다.
- [x] **GREEN 확인:** 같은 focused test와 직접 영향 회귀 테스트가 성공하는지 확인한다.
- [x] **REFACTOR:** 신규 추상화 없이 중복만 정리하고 compile, ktlint, diff 검증 결과를 기록한다.
### Task 2.R1 비라이브 ID fallback 판별 회귀 수정
**Goal 실행 `P2-R1`:** `REV-P2-001`에서 확인된 route 판별과 실제 실행의 ID fallback 차이를 제거한다.
- **시작 조건:** `REV-P2-001` 확정.
- **대상 파일:** `MainV2Activity.kt`, `MainV2ActivitySourceTest.kt`
- **수정 범위:** 문자열 ID가 숫자로 파싱되면 그 값의 양수 여부를 사용하고, 파싱되지 않을 때만 Long extra로 fallback한다.
- **회귀 테스트:** 문자열 ID `0`/음수와 양수 Long extra가 함께 있어도 실제 router와 동일하게 live fallback을 선택하는 source contract.
- **완료 증거:** RED assertion 실패, focused GREEN, Phase 2 Gate 재실행, review 재검토.
- [x] **RED:** `hasPositiveId()`가 `executeBundleRoute()`와 동일한 Elvis fallback 순서를 사용하는지 검증한다.
- [x] **RED 확인:** focused test에서 기존 OR 판별 때문에 assertion이 실패하는지 확인한다.
- [x] **GREEN:** `hasPositiveId()` 값 선택 순서만 최소 수정한다.
- [x] **GREEN 확인:** focused test와 Phase 2 Gate를 다시 실행한다.
- [x] **REFACTOR:** 추가 변경 없이 리뷰 보고서와 Verification Log에 증거를 누적한다.
### 시나리오 계약
| 시나리오 | 통과 조건 | 검증 surface | Test ID |
|---|---|---|---|
| foreground 라이브 이동 | `LiveRoomActivity.isForeground`이고 실제 live route일 때 loading을 표시하고 1000ms 뒤 처리한다. | source test | `MainV2Activity는 foreground 라이브 딥링크만 로딩과 함께 지연한다` |
| 종료 경계 | Activity 종료 시 pending route callback과 loading dialog가 남지 않는다. | source test | `MainV2Activity는 종료할 때 지연 딥링크와 로딩을 정리한다` |
| 인접 회귀 | 채팅·오디오·일반 딥링크는 즉시 처리되고 direct entry의 `CLEAR_TOP | SINGLE_TOP`이 유지된다. | source test | 기존 audio/direct-entry test 및 선택적 지연 test |
### Phase 2 Gate
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2DirectEntrySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest" --tests "kr.co.vividnext.sodalive.v2.live.action.*" --max-workers=1
./gradlew :app:compileDebugKotlin --max-workers=1
./gradlew :app:ktlintCheck --max-workers=1
git diff --check
```
**Expected:** 모든 명령 exit code 0, foreground live route만 1초 지연, 기존 direct-entry flags 유지.
## Phase 3
**Phase 결과:** `live.reservation.status` package 선언과 물리 디렉터리 구조가 일치한다.
### Task 3.1 예약 status package 경로 이동
**Goal 실행 `P3-T1`:** 기존 package 선언을 유지하며 대상 Kotlin 파일 6개를 `live/reservation/status/`로 이동한다.
**Files:**
- Move: `app/src/main/java/kr/co/vividnext/sodalive/live/reservation_status/*.kt`
- To: `app/src/main/java/kr/co/vividnext/sodalive/live/reservation/status/*.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/live/LiveReservationPackageSourceTest.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2DirectEntrySourceTest.kt`
- [x] **RED:** 새 디렉터리에 6개 파일이 존재하고 기존 디렉터리가 없음을 검증하는 source test를 추가한다.
- [x] **RED 확인:** focused test가 새 경로 부재 assertion으로 실패하는지 확인한다.
- [x] **GREEN:** Kotlin 파일을 새 경로로 이동하고 source test의 파일 경로를 갱신한다.
- [x] **GREEN 확인:** package source test와 direct-entry source test가 성공하는지 확인한다.
- [x] **REFACTOR:** resource 이름은 변경하지 않고 compile, ktlint, diff check 결과를 기록한다.
### Phase 3 Gate
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.live.LiveReservationPackageSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2DirectEntrySourceTest" --max-workers=1
./gradlew :app:compileDebugKotlin --max-workers=1
./gradlew :app:ktlintCheck --max-workers=1
git diff --check
```
**Expected:** 대상 6개 파일은 `live/reservation/status/`에만 존재하고 모든 명령 exit code 0.
## Verification Log
- 2026-09-18: Phase 2 RED에서 신규 선택적 지연·종료 정리 test 2개가 기존 즉시 처리 때문에 실패했고 기존 test 12개는 통과했다.
- 2026-09-18: Phase 2 GREEN에서 `MainV2ActivitySourceTest` 14개와 direct-entry/audio/Live Action focused 회귀가 성공했다. `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check`도 성공했다.
- 2026-09-18: 전체 `:app:testDebugUnitTest`는 1,267개 중 기존 `ChatMainFragmentLayoutTest:71`, `ChatMainTitleBarActionSourceTest:16` 2개만 실패했다.
- 2026-09-18: 5개 병렬 리뷰에서 보안·자동 QA·맥락 검토는 통과했고, 목표·코드 품질 리뷰가 `REV-P2-001` fallback 판별 불일치를 확정했다.
- 2026-09-18: `P2-R1` RED에서 `MainV2ActivitySourceTest` 15개 중 신규 fallback test 1개만 실패했다. 최소 수정 후 같은 test와 Phase 2 focused 회귀, compile, ktlint, diff check가 성공했다.
- 2026-09-18: 수정 후 전체 `:app:testDebugUnitTest`는 1,268개 중 동일한 기존 채팅 source test 2개만 실패했다. 목표·코드 품질 Oracle 재검토는 모두 `REV-P2-001` 해결 및 신규 blocker 없음으로 승인했다.
- 2026-09-18: Phase 3 RED에서 `LiveReservationPackageSourceTest` 3개 중 신규 경로 정합성 test 1개가 새 디렉터리 부재로 실패했다.
- 2026-09-18: Phase 3 GREEN에서 package/direct-entry source test 5개, `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check`가 성공했다. 대상 Kotlin 파일 6개는 `live/reservation/status/`에만 존재하고 resource 변경은 없다.
- 2026-09-18: 전체 `:app:testDebugUnitTest`는 1,269개 중 기존 `ChatMainFragmentLayoutTest:71`, `ChatMainTitleBarActionSourceTest:16` 2개만 실패했다.
- 2026-09-18: Phase 3 목표·자동 QA·코드 품질·보안·맥락 검토 5개가 모두 통과했으며 확정 발견 사항은 없다.
@@ -0,0 +1,75 @@
# 레거시 메인 직접 진입 V2 전환 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-09-18 |
| 최종 수정일 | 2026-09-18 |
| 대상 제품 | Android SodaLive 메인 라우팅 |
| 관련 구현 계획 | `docs/20260918_레거시_메인_직접_진입_v2_전환/plan-task.md` |
| 관련 review | `docs/20260918_레거시_메인_직접_진입_v2_전환/reviews/phase1-main-v2-direct-entry-review.md`<br>`docs/20260918_레거시_메인_직접_진입_v2_전환/reviews/phase2-live-deeplink-deferral-review.md`<br>`docs/20260918_레거시_메인_직접_진입_v2_전환/reviews/phase3-reservation-status-package-path-review.md` |
## 1. Overview
레거시 `MainActivity`를 직접 여는 두 사용자 흐름을 `MainV2Activity`로 전환한다. 기존 딥링크 데이터와 Activity stack 정리 동작은 유지한다.
## 2. Problem Statement
- 라이브룸에서 다른 라이브 푸시 이동을 확인하면 레거시 `MainActivity`를 경유한다.
- 라이브 예약 취소 화면의 "다른 수다 예약" 동작도 task를 비운 뒤 레거시 `MainActivity`를 연다.
- 이 때문에 후속 화면 종료 시 현재 제품 기준인 V2 메인이 아닌 레거시 메인이 표시될 수 있다.
## 3. Goals
- 두 직접 진입 경로가 `MainV2Activity`를 연다.
- 라이브룸 딥링크 경로의 `Constants.EXTRA_DATA`, `FLAG_ACTIVITY_CLEAR_TOP`, `FLAG_ACTIVITY_SINGLE_TOP`을 유지한다.
- 예약 취소 경로의 `finishAffinity()` 동작을 유지한다.
- 라이브룸이 foreground인 상태에서 V2 메인이 받은 라이브룸 딥링크만 1초 지연하고, 대기 및 입장 처리 중 기존 로딩 다이얼로그를 표시한다.
- `live.reservation.status` package 파일의 물리 디렉터리를 package 경로와 일치시킨다.
## 4. Non-Goals
- 레거시 `MainActivity` 내부 기능 또는 Fragment를 수정하지 않는다.
- 다른 Activity/Fragment의 내비게이션 구조를 리팩터링하지 않는다.
- 새로운 라우팅 추상화나 의존성을 추가하지 않는다.
## 5. 핵심 사용자 흐름
1. 라이브룸에서 다른 라이브 푸시를 누르고 이동을 확인하면 V2 메인이 딥링크 bundle을 처리해 대상 라이브로 이동한다.
2. 이동한 라이브룸에서 나가면 V2 메인이 표시된다.
3. 라이브 예약 취소 화면에서 "다른 수다 예약"을 누르면 기존 task를 정리하고 V2 메인이 표시된다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|---|---|---|---|---|
| `ROUTE-001` | 확정 | 라이브룸 딥링크 확인 경로는 `MainV2Activity`를 사용한다. | target이 `MainV2Activity`이고 기존 bundle/flags가 유지된다. | `P1-T1` |
| `ROUTE-002` | 확정 | 예약 취소의 다른 수다 예약 경로는 `MainV2Activity`를 사용한다. | target이 `MainV2Activity`이고 `finishAffinity()`가 유지된다. | `P1-T1` |
| `ROUTE-003` | 확정 | 대상 두 파일에서 레거시 `MainActivity` 직접 참조를 제거한다. | source test에서 import와 Intent target 부재를 확인한다. | `P1-T1` |
| `ROUTE-004` | 확정 | `MainV2Activity.onNewIntent()`는 foreground 라이브룸에서 전달된 라이브룸 딥링크만 1초 지연한다. | 채팅·오디오·일반 딥링크는 기존 즉시 처리 경로를 유지하고 `SINGLE_TOP`도 유지된다. | `P2-T1` |
| `ROUTE-005` | 확정 | 지연 중에는 기존 `LoadingDialog`를 표시하고 라이브 입장 loading 상태로 표시 책임을 인계한다. | deferred 또는 live entry loading 중에만 다이얼로그가 표시되고 두 상태가 모두 끝나면 해제된다. | `P2-T1` |
| `ROUTE-006` | 확정 | Activity 종료 후 지연된 딥링크가 실행되지 않는다. | `onDestroy()`에서 해당 callback을 제거하고 다이얼로그를 해제한다. | `P2-T1` |
| `ROUTE-007` | 확정 | `live.reservation.status` package 파일은 `live/reservation/status/`에 위치한다. | 대상 6개 파일이 새 경로에 있고 기존 `live/reservation_status/` 경로는 존재하지 않는다. | `P3-T1` |
## 7. 오류 처리와 보안
- 기존 Intent extra와 Activity flag를 그대로 유지하므로 신규 오류 처리는 추가하지 않는다.
- 인증 정보, 토큰, URL을 새로 저장하거나 로그로 출력하지 않는다.
## 8. 테스트 및 수용 기준
- source test가 두 경로의 `MainV2Activity` target과 레거시 target 부재를 검증한다.
- focused unit test와 Kotlin compile이 성공한다.
- 사용자 요청에 따라 기기·에뮬레이터 UI 테스트는 수행하지 않는다.
- source test가 foreground 라이브룸 딥링크의 선택적 지연, 기존 loading 결합 조건, 종료 정리를 검증한다.
## 9. Decision Log
| 날짜 | 결정 | 이유 |
|---|---|---|
| 2026-09-18 | 두 직접 진입 경로를 함께 V2로 전환 | 사용자가 두 곳 모두 수정을 확정했다. |
| 2026-09-18 | 기존 flags, extras, `finishAffinity()` 유지 | 메인 target 외 동작 변경을 피한다. |
| 2026-09-18 | foreground 라이브룸 딥링크에만 1초 지연과 기존 로딩 다이얼로그를 적용 | 레거시 진입 경로가 제공하던 정리 시간을 재현하면서 다른 딥링크와 `MainV2Activity` 단일 인스턴스 동작을 유지한다. |
| 2026-09-18 | `reservation_status` 물리 디렉터리를 `reservation/status`로 이동 | package 선언과 소스 경로를 일치시키고 ktlint 허용 package명을 유지한다. |
@@ -0,0 +1,63 @@
# Phase 1 Main V2 Direct Entry 코드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1 |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | 2026-09-18 |
| 리뷰어 | Oracle |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 두 직접 진입 경로가 기존 부가 동작을 유지하면서 `MainV2Activity`를 사용하는지 확인했다.
- 대상 production 파일 2개, 신규 source test, PRD와 계획 문서를 검토했다.
- 레거시 Fragment, 기기·에뮬레이터 UI 검증은 범위에서 제외했다.
## 3. 실행한 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| focused test RED | 실패 확인 | 신규 test 2개가 기존 V2 import 부재로 실패 |
| focused test GREEN | 성공 | `MainV2DirectEntrySourceTest` 2개 통과 |
| `:app:compileDebugKotlin` | 성공 | Kotlin compile 완료 |
| 전체 `:app:testDebugUnitTest` | 기존 실패 | 1,263개 중 무관한 chat source test 2개 실패 |
| `:app:ktlintCheck` | 성공 | 후속 수정 후 package underscore 위반 해소 |
| 직접 진입 source 검색 | 성공 | 대상 두 파일의 legacy `MainActivity` 직접 참조 0건 |
| `git diff --check` | 성공 | whitespace 오류 없음 |
## 4. 발견 사항 요약
확정 발견 사항 없음.
비차단 참고 사항으로 source test가 호출 순서까지 assertion으로 고정하지는 않지만, production diff에서 기존 `startActivity`/`finish` 및 `finishAffinity`/`startActivity` 순서가 유지됨을 확인했다.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 코드·test·문서 검토 완료 |
| 후보 항목 판정 완료 | 충족 | 차단 발견 사항 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | `plan-task.md` Verification Log에 누적 |
**최종 결론:** 확정 발견 사항 없음. 변경 승인.
**남은 항목:** 이번 변경과 무관한 전체 unit test 2건.
## 7. 후속 ktlint 수정 검증 — 2026-09-18
- 무엇을: `live.reservation_status` package underscore 위반을 `live.reservation.status`로 수정했다.
- 왜: `:app:ktlintCheck`의 package naming 위반을 해소하기 위해서다.
- 어떻게:
- `LiveReservationPackageSourceTest` RED — 기존 package/import/manifest 참조 때문에 2개 실패.
- `LiveReservationPackageSourceTest`, `MainV2DirectEntrySourceTest`, `:app:compileDebugKotlin` — 성공.
- `:app:ktlintCheck` — 성공.
- production/manifest의 `reservation_status` package/import/manifest 참조 검색 — 0건.
@@ -0,0 +1,75 @@
# Phase 2 라이브 딥링크 지연 처리 코드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1 |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | 2026-09-18 |
| 리뷰어 | Oracle 3명, 자동 QA, 저장소 맥락 검토 agent |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 수정 검증 완료 |
## 2. 목적과 범위
- foreground live route 선택적 지연, loading 인계, lifecycle 정리와 인접 route 회귀를 검토한다.
- `MainV2Activity.kt`, 관련 source test, direct-entry flags와 Live entry 호출 흐름을 포함한다.
- 저장소 규칙에 따라 기기·에뮬레이터 UI 검증은 제외한다.
## 3. 실행한 검증
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| focused MainV2/Live Action unit test | 성공 | 48개 통과 |
| `:app:compileDebugKotlin` | 성공 | exit code 0 |
| `:app:ktlintCheck` | 성공 | exit code 0, 기존 deprecation warning만 존재 |
| `:app:testDebugUnitTest` | 실패 | 1,267개 중 기존 채팅 source test 2개 실패 |
| `git diff --check` | 성공 | 출력 없음 |
## 4. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P2-001` | Medium | 수정 완료 | 비라이브 ID fallback 판별이 실제 router와 다름 | Task 2.R1 | `P2-R1` |
## 5. 발견 사항 상세
### REV-P2-001 — 비라이브 ID fallback 판별 불일치
- **관련 요구사항:** `ROUTE-004`
- **소유 Task:** Task 2.R1
- **상태:** 수정 완료
- **관찰:** 수정 전 `hasPositiveId()`는 문자열 ID와 Long extra 중 하나라도 양수면 참이었지만, 실제 router는 파싱 가능한 문자열 값을 우선 선택하고 파싱 실패일 때만 Long extra를 사용했다.
- **재현:** `channel_id="0"`, 양수 `EXTRA_USER_ID`, `deep_link_value="live"`, 양수 `deep_link_sub5`가 함께 있으면 판별은 non-live지만 실제 실행은 live fallback이다.
- **영향:** foreground live route가 1초 지연을 우회할 수 있다.
- **권장 조치:** 값 선택 순서를 실제 router와 같게 만들고 source regression assertion을 추가한다.
- **판정 기록:** 2026-09-18 — 목표/코드 품질 Oracle 2명이 독립적으로 동일 경계 사례를 확인해 확정했다.
- **판정 기록:** 2026-09-18 — Elvis fallback 순서로 수정하고 회귀 test를 추가한 뒤 두 Oracle 재검토에서 해결을 승인했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 5개 병렬 리뷰 완료 |
| 후보 항목 판정 완료 | 충족 | `REV-P2-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 2.R1 / `P2-R1` |
| 검증 명령과 결과 기록 | 충족 | 위 실행 결과 |
**최종 결론:** 수정 검증 완료
**남은 항목:** 없음
## 7. 수정 후 검증 기록
### 1차 수정 검증 — 2026-09-18
- 무엇을: `REV-P2-001`의 문자열/Long ID fallback 판별을 실제 router와 일치시켰다.
- 왜: 혼합 bundle에서 실제 live route가 1초 지연을 우회할 수 있었다.
- 어떻게:
- fallback source test RED — 15개 중 신규 1개 실패.
- 수정 후 focused Phase 2 회귀 — 성공.
- `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check` — 성공.
- 전체 unit test — 1,268개 중 기존 채팅 source test 2개 실패.
- 목표·코드 품질 Oracle 재검토 — 모두 PASS.
- 남은 항목: 없음.
@@ -0,0 +1,53 @@
# Phase 3 예약 status package 경로 코드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1 |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | 2026-09-18 |
| 리뷰어 | Oracle 3명, 자동 QA, 저장소 맥락 검토 agent |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- `live.reservation.status` package 선언과 Kotlin 파일의 물리 디렉터리가 일치하는지 확인한다.
- 이동 대상 6개 파일, 경로 기반 source test, manifest·import·빌드 설정과 resource 보존 여부를 검토한다.
- 이전 Phase의 동작 변경과 기기·에뮬레이터 UI 검증은 범위에서 제외한다.
## 3. 실행한 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| focused source test RED | 실패 확인 | 3개 중 신규 경로 정합성 test 1개가 새 디렉터리 부재로 실패 |
| focused source test GREEN | 성공 | package test 3개와 direct-entry test 2개 통과 |
| `:app:compileDebugKotlin` | 성공 | QA 재실행을 포함해 exit code 0 |
| `:app:ktlintCheck` | 성공 | 기존 `.editorconfig` deprecation warning만 존재 |
| 전체 `:app:testDebugUnitTest` | 기존 실패 | 1,269개 중 무관한 chat source test 2개 실패 |
| `git diff --check` | 성공 | 출력 없음 |
| 경로·참조·resource 조사 | 성공 | 새 경로에 6개, 구 디렉터리 없음, production 구 참조와 resource diff 없음 |
## 4. 발견 사항 요약
확정 발견 사항 없음.
목표 적합성, 자동 QA, 코드 품질, 보안, 저장소 맥락 검토 5개가 모두 통과했다. 코드 품질과 맥락 검토에서 Phase 3 완료 기록 미반영을 비차단 항목으로 확인했으며, 본 문서와 `plan-task.md`에 반영했다.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 5개 병렬 리뷰 완료 |
| 후보 항목 판정 완료 | 충족 | 차단 및 확정 발견 사항 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 본 문서와 `plan-task.md`에 누적 |
**최종 결론:** 확정 발견 사항 없음. 변경 승인.
**남은 항목:** 이번 변경과 무관한 전체 unit test 2건.