Compare commits
30
Commits
1f8d0beb2f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb727d0ee | ||
|
|
48f2a0e543 | ||
|
|
c261b32148 | ||
|
|
cc8f271f44 | ||
|
|
086af8a366 | ||
|
|
5e114e18a0 | ||
|
|
330148821c | ||
|
|
1d38912474 | ||
|
|
2e67e092a5 | ||
|
|
8749ae9ae7 | ||
|
|
81b5a1dafc | ||
|
|
582b4fe703 | ||
|
|
287ba0b01b | ||
|
|
7b11185db4 | ||
|
|
5029332023 | ||
|
|
f891c15eb9 | ||
|
|
9bb0a6512e | ||
|
|
4bc725bdf2 | ||
|
|
5e1a7e9ace | ||
|
|
e57998654b | ||
|
|
7c2ea3a02b | ||
|
|
a6c63b71b9 | ||
|
|
4f6b235d60 | ||
|
|
127e5fc147 | ||
|
|
be847b4600 | ||
|
|
c6bee79f8e | ||
|
|
e9bb2c6dd8 | ||
|
|
9de82916dd | ||
|
|
f454596df5 | ||
|
|
22a24cb1c1 |
+2
-2
@@ -86,8 +86,8 @@ android {
|
|||||||
applicationId "kr.co.vividnext.sodalive"
|
applicationId "kr.co.vividnext.sodalive"
|
||||||
minSdk 23
|
minSdk 23
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 250
|
versionCode 254
|
||||||
versionName "1.56.1"
|
versionName "1.56.3"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,9 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:replace="android:allowBackup"
|
tools:replace="android:allowBackup"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name="kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".main.DeepLinkActivity"
|
android:name=".main.DeepLinkActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -174,8 +177,8 @@
|
|||||||
<activity android:name=".settings.notification.NotificationSettingsActivity" />
|
<activity android:name=".settings.notification.NotificationSettingsActivity" />
|
||||||
<activity android:name=".settings.notification.NotificationReceiveSettingsActivity" />
|
<activity android:name=".settings.notification.NotificationReceiveSettingsActivity" />
|
||||||
<activity android:name=".settings.ContentSettingsActivity" />
|
<activity android:name=".settings.ContentSettingsActivity" />
|
||||||
<activity android:name=".live.reservation_status.LiveReservationStatusActivity" />
|
<activity android:name=".live.reservation.status.LiveReservationStatusActivity" />
|
||||||
<activity android:name=".live.reservation_status.LiveReservationCancelActivity" />
|
<activity android:name=".live.reservation.status.LiveReservationCancelActivity" />
|
||||||
<activity android:name=".audio_content.AudioContentActivity" />
|
<activity android:name=".audio_content.AudioContentActivity" />
|
||||||
<activity android:name=".audio_content.detail.AudioContentDetailActivity" />
|
<activity android:name=".audio_content.detail.AudioContentDetailActivity" />
|
||||||
<activity android:name=".audio_content.modify.AudioContentModifyActivity" />
|
<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() {
|
fun clear() {
|
||||||
ensureInitialized()
|
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.LiveViewModel
|
||||||
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendApi
|
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendApi
|
||||||
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepository
|
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.LiveRoomViewModel
|
||||||
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateViewModel
|
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateViewModel
|
||||||
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailViewModel
|
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.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
|
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
|
||||||
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse
|
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.CancelLiveReservationRequest
|
||||||
import kr.co.vividnext.sodalive.live.reservation_status.GetLiveReservationResponse
|
import kr.co.vividnext.sodalive.live.reservation.status.GetLiveReservationResponse
|
||||||
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
|
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
|
||||||
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
|
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
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 io.reactivex.rxjava3.core.Single
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
|
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.CancelLiveRequest
|
||||||
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
|
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
|
|||||||
+1
-1
@@ -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 androidx.annotation.Keep
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
+1
-1
@@ -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 android.os.Parcelable
|
||||||
import androidx.annotation.Keep
|
import androidx.annotation.Keep
|
||||||
+3
-3
@@ -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.annotation.SuppressLint
|
||||||
import android.content.Intent
|
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.databinding.ActivityLiveReservationCancelBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.convertDateFormat
|
import kr.co.vividnext.sodalive.extensions.convertDateFormat
|
||||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
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.mypage.can.status.CanStatusActivity
|
||||||
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -117,7 +117,7 @@ class LiveReservationCancelActivity : BaseActivity<ActivityLiveReservationCancel
|
|||||||
|
|
||||||
binding.tvOtherSudaReservation.setOnClickListener {
|
binding.tvOtherSudaReservation.setOnClickListener {
|
||||||
finishAffinity()
|
finishAffinity()
|
||||||
startActivity(Intent(applicationContext, MainActivity::class.java))
|
startActivity(Intent(applicationContext, MainV2Activity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.tvCheckCanStatus.setOnClickListener {
|
binding.tvCheckCanStatus.setOnClickListener {
|
||||||
+1
-1
@@ -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.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
+1
-1
@@ -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.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
+1
-1
@@ -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.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
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.RouletteSpinDialog
|
||||||
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
|
import kr.co.vividnext.sodalive.live.roulette.config.RouletteConfigActivity
|
||||||
import kr.co.vividnext.sodalive.main.DeepLinkActivity
|
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.ProfileReportDialog
|
||||||
import kr.co.vividnext.sodalive.report.ReportType
|
import kr.co.vividnext.sodalive.report.ReportType
|
||||||
import kr.co.vividnext.sodalive.report.UserReportDialog
|
import kr.co.vividnext.sodalive.report.UserReportDialog
|
||||||
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
|
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
|
||||||
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||||
import kr.co.vividnext.sodalive.settings.notification.MemberRole
|
import kr.co.vividnext.sodalive.settings.notification.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -379,7 +379,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
|||||||
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
|
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
|
||||||
|
|
||||||
showDeepLinkNavigationDialog {
|
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)
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
putExtra(Constants.EXTRA_DATA, bundle)
|
putExtra(Constants.EXTRA_DATA, bundle)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -30,6 +30,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
|
|||||||
import kr.co.vividnext.sodalive.common.Constants
|
import kr.co.vividnext.sodalive.common.Constants
|
||||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
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.databinding.ActivityCreatorChannelBinding
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog
|
||||||
@@ -230,6 +231,7 @@ class CreatorChannelActivity :
|
|||||||
private fun setupClickListeners() {
|
private fun setupClickListeners() {
|
||||||
binding.ivBack.setOnClickListener { finish() }
|
binding.ivBack.setOnClickListener { finish() }
|
||||||
binding.ivMore.setOnClickListener { onMoreClicked() }
|
binding.ivMore.setOnClickListener { onMoreClicked() }
|
||||||
|
binding.ivShare.setOnClickListener { shareCurrentCreatorChannel() }
|
||||||
binding.tvFollowerCount.setOnClickListener { onFollowerCountClicked() }
|
binding.tvFollowerCount.setOnClickListener { onFollowerCountClicked() }
|
||||||
binding.layoutFollowCapsule.setOnClickListener { onFollowCapsuleClicked() }
|
binding.layoutFollowCapsule.setOnClickListener { onFollowCapsuleClicked() }
|
||||||
binding.ivBell.setOnClickListener { onBellClicked() }
|
binding.ivBell.setOnClickListener { onBellClicked() }
|
||||||
@@ -291,9 +293,11 @@ class CreatorChannelActivity :
|
|||||||
binding.layoutFollowCapsule.visibility = View.GONE
|
binding.layoutFollowCapsule.visibility = View.GONE
|
||||||
binding.ivBell.visibility = View.GONE
|
binding.ivBell.visibility = View.GONE
|
||||||
binding.ivMore.visibility = View.GONE
|
binding.ivMore.visibility = View.GONE
|
||||||
|
binding.ivShare.visibility = View.VISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.ivShare.visibility = View.GONE
|
||||||
binding.layoutFollowCapsule.visibility = View.VISIBLE
|
binding.layoutFollowCapsule.visibility = View.VISIBLE
|
||||||
binding.ivMore.visibility = View.VISIBLE
|
binding.ivMore.visibility = View.VISIBLE
|
||||||
val titleBarState = CreatorChannelTitleBarState.from(
|
val titleBarState = CreatorChannelTitleBarState.from(
|
||||||
@@ -435,12 +439,35 @@ class CreatorChannelActivity :
|
|||||||
if (header.isOwner) return
|
if (header.isOwner) return
|
||||||
|
|
||||||
CreatorChannelMoreBottomSheet.newInstance().apply {
|
CreatorChannelMoreBottomSheet.newInstance().apply {
|
||||||
|
onClickChannelShare = { shareCurrentCreatorChannel() }
|
||||||
onClickBlock = { showUserBlockDialog() }
|
onClickBlock = { showUserBlockDialog() }
|
||||||
onClickUserReport = { showUserReportDialog() }
|
onClickUserReport = { showUserReportDialog() }
|
||||||
onClickProfileReport = { showProfileReportDialog() }
|
onClickProfileReport = { showProfileReportDialog() }
|
||||||
}.show(supportFragmentManager, CreatorChannelMoreBottomSheet::class.java.simpleName)
|
}.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() {
|
private fun showUserBlockDialog() {
|
||||||
val nickname = currentHeader?.nickname.orEmpty()
|
val nickname = currentHeader?.nickname.orEmpty()
|
||||||
V2ModalDialog(
|
V2ModalDialog(
|
||||||
|
|||||||
+5
@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.databinding.DialogCreatorChannelMoreBinding
|
|||||||
|
|
||||||
class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
|
class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
var onClickChannelShare: (() -> Unit)? = null
|
||||||
var onClickBlock: (() -> Unit)? = null
|
var onClickBlock: (() -> Unit)? = null
|
||||||
var onClickUserReport: (() -> Unit)? = null
|
var onClickUserReport: (() -> Unit)? = null
|
||||||
var onClickProfileReport: (() -> Unit)? = null
|
var onClickProfileReport: (() -> Unit)? = null
|
||||||
@@ -29,6 +30,10 @@ class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val viewBinding = binding ?: return
|
val viewBinding = binding ?: return
|
||||||
|
|
||||||
|
viewBinding.tvChannelShare.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
onClickChannelShare?.invoke()
|
||||||
|
}
|
||||||
viewBinding.tvUserBlock.setOnClickListener {
|
viewBinding.tvUserBlock.setOnClickListener {
|
||||||
dismiss()
|
dismiss()
|
||||||
onClickBlock?.invoke()
|
onClickBlock?.invoke()
|
||||||
|
|||||||
+6
@@ -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.loadUrl
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
|
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.reply.CreatorChannelCommunityReplyActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
|
||||||
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
|
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
|
||||||
@@ -174,6 +175,11 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
|
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
|
||||||
tvCreatorChannelCommunityDetailTime.text =
|
tvCreatorChannelCommunityDetailTime.text =
|
||||||
formatUtcRelativeTimeText(tvCreatorChannelCommunityDetailTime.context, post.createdAtUtc)
|
formatUtcRelativeTimeText(tvCreatorChannelCommunityDetailTime.context, post.createdAtUtc)
|
||||||
|
layoutCreatorChannelCommunityDetailCreator.setOnClickListener {
|
||||||
|
startActivity(
|
||||||
|
CreatorChannelActivity.newIntent(this@CreatorChannelCommunityDetailActivity, post.creatorId)
|
||||||
|
)
|
||||||
|
}
|
||||||
btnCreatorChannelCommunityDetailMore.isVisible = post.showMore
|
btnCreatorChannelCommunityDetailMore.isVisible = post.showMore
|
||||||
tvCreatorChannelCommunityDetailBody.text = post.content
|
tvCreatorChannelCommunityDetailBody.text = post.content
|
||||||
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
|
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main
|
package kr.co.vividnext.sodalive.v2.main
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.animation.ValueAnimator
|
import android.animation.ValueAnimator
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -38,6 +38,8 @@ import com.google.firebase.messaging.FirebaseMessaging
|
|||||||
import com.gun0912.tedpermission.PermissionListener
|
import com.gun0912.tedpermission.PermissionListener
|
||||||
import com.gun0912.tedpermission.normal.TedPermission
|
import com.gun0912.tedpermission.normal.TedPermission
|
||||||
import com.orhanobut.logger.Logger
|
import com.orhanobut.logger.Logger
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
||||||
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
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.explorer.profile.creator_community.write.CreatorCommunityWriteActivity
|
||||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||||
import kr.co.vividnext.sodalive.live.LiveViewModel
|
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.live.room.create.LiveRoomCreateActivity
|
||||||
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
|
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
|
||||||
import kr.co.vividnext.sodalive.message.MessageActivity
|
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.ContentMainFragment
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.HomeMainFragment
|
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 org.koin.android.ext.android.inject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
@@ -108,6 +108,18 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
|
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
|
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 val audioContentReceiver = AudioContentReceiver()
|
||||||
private var playerStateJob: Job? = null
|
private var playerStateJob: Job? = null
|
||||||
private var isDeferredRouteLoading = false
|
private var isDeferredRouteLoading = false
|
||||||
@@ -145,13 +157,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
trackAppLaunchIfNeeded()
|
trackAppLaunchIfNeeded()
|
||||||
pushTokenUpdate()
|
pushTokenUpdate()
|
||||||
|
|
||||||
handler.postDelayed({
|
handler.postDelayed(deferredRouteRunnable, 1000)
|
||||||
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
|
|
||||||
executeDeeplink(intent)
|
|
||||||
}
|
|
||||||
isDeferredRouteLoading = false
|
|
||||||
updateRouteLoadingDialog()
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
if (isV2AccessAllowed(AccessRequirement.Login)) {
|
if (isV2AccessAllowed(AccessRequirement.Login)) {
|
||||||
updatePidAndGaid()
|
updatePidAndGaid()
|
||||||
@@ -178,15 +184,27 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
handler.removeCallbacks(deferredRouteRunnable)
|
||||||
|
isDeferredRouteLoading = false
|
||||||
|
updateRouteLoadingDialog()
|
||||||
|
|
||||||
if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
|
if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
|
||||||
openChatWithInitialFilter()
|
openChatWithInitialFilter()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!handleAudioNotificationRoute(intent) && isV2AccessAllowed(AccessRequirement.Login)) {
|
if (handleAudioNotificationRoute(intent) || !isV2AccessAllowed(AccessRequirement.Login)) {
|
||||||
executeDeeplink(intent)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LiveRoomActivity.isForeground && isLiveRoomDeepLink(intent)) {
|
||||||
|
isDeferredRouteLoading = true
|
||||||
|
updateRouteLoadingDialog()
|
||||||
|
handler.postDelayed(deferredRouteRunnable, 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
executeDeeplink(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
@@ -213,6 +231,9 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
handler.removeCallbacks(deferredRouteRunnable)
|
||||||
|
isDeferredRouteLoading = false
|
||||||
|
routeLoadingDialog.dismiss()
|
||||||
deInitMiniPlayer()
|
deInitMiniPlayer()
|
||||||
playerStateJob?.cancel()
|
playerStateJob?.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -315,15 +336,18 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
viewModel.clickTab(MainV2Tab.HOME)
|
viewModel.clickTab(MainV2Tab.HOME)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.menu_main_v2_content -> {
|
R.id.menu_main_v2_content -> {
|
||||||
viewModel.clickTab(MainV2Tab.CONTENT)
|
viewModel.clickTab(MainV2Tab.CONTENT)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.menu_main_v2_chat -> selectChatTabWithLoginGuard()
|
R.id.menu_main_v2_chat -> selectChatTabWithLoginGuard()
|
||||||
R.id.menu_main_v2_my -> {
|
R.id.menu_main_v2_my -> {
|
||||||
viewModel.clickTab(MainV2Tab.MY)
|
viewModel.clickTab(MainV2Tab.MY)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,19 +781,53 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun executeDeeplink(intent: Intent) {
|
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 deepLinkUrl = bundle.getString("deep_link")
|
||||||
val routeBundle = if (!deepLinkUrl.isNullOrBlank()) {
|
return if (!deepLinkUrl.isNullOrBlank()) {
|
||||||
Bundle(bundle).apply {
|
Bundle(bundle).apply {
|
||||||
buildBundleFromDeepLinkUrl(deepLinkUrl)?.let { putAll(it) }
|
buildBundleFromDeepLinkUrl(deepLinkUrl)?.let { putAll(it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bundle
|
bundle
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (executeBundleRoute(routeBundle)) {
|
private fun isLiveRoomDeepLink(intent: Intent): Boolean {
|
||||||
clearDeferredDeepLink()
|
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? {
|
private fun buildBundleFromDeepLinkUrl(deepLinkUrl: String): Bundle? {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.v2.main.chat
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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.R
|
||||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
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.databinding.FragmentV2MainChatBinding
|
||||||
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
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.AccessRequirement
|
||||||
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
|
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
|
||||||
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
|
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.ChatRoomListUiState
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
|
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.model.emptyMessageResId
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
|
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
@@ -51,12 +51,19 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
private var isChatPullRefreshing = false
|
private var isChatPullRefreshing = false
|
||||||
private var refreshingChatFilter: ChatRoomFilter? = null
|
private var refreshingChatFilter: ChatRoomFilter? = null
|
||||||
private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null
|
private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null
|
||||||
|
private var shouldRefreshChatRoomsOnResume = false
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
shouldRefreshChatRoomsOnResume = false
|
||||||
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
||||||
|
|
||||||
setupTitleBar()
|
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
|
val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL
|
||||||
selectedFilter = initialFilter
|
selectedFilter = initialFilter
|
||||||
setupFilterTabs(initialFilter)
|
setupFilterTabs(initialFilter)
|
||||||
@@ -67,6 +74,17 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
viewModel.loadFirstPage(initialFilter)
|
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) {
|
fun selectFilter(filter: ChatRoomFilter) {
|
||||||
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
|
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
|
||||||
cancelChatPullRefresh()
|
cancelChatPullRefresh()
|
||||||
@@ -82,20 +100,6 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
binding.viewChatTitleBar.ivTitleBarMenu.setOnClickListener {
|
binding.viewChatTitleBar.ivTitleBarMenu.setOnClickListener {
|
||||||
openWithLoginGuard(CanChargeActivity::class.java)
|
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<*>) {
|
private fun openWithLoginGuard(activityClass: Class<*>) {
|
||||||
@@ -174,6 +178,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
binding.rvChatRooms.scrollToPosition(0)
|
binding.rvChatRooms.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatRoomListUiState.Empty -> {
|
ChatRoomListUiState.Empty -> {
|
||||||
lastRenderedChatContentState = null
|
lastRenderedChatContentState = null
|
||||||
bindChatRooms(emptyList(), showEmpty = true)
|
bindChatRooms(emptyList(), showEmpty = true)
|
||||||
@@ -182,6 +187,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
refreshingChatFilter = null
|
refreshingChatFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatRoomListUiState.Error -> {
|
is ChatRoomListUiState.Error -> {
|
||||||
lastRenderedChatContentState = null
|
lastRenderedChatContentState = null
|
||||||
if (!isChatPullRefreshing || !isRefreshForCurrentFilter) {
|
if (!isChatPullRefreshing || !isRefreshForCurrentFilter) {
|
||||||
@@ -192,6 +198,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
refreshingChatFilter = null
|
refreshingChatFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatRoomListUiState.Loading -> {
|
ChatRoomListUiState.Loading -> {
|
||||||
if (!isChatPullRefreshing) {
|
if (!isChatPullRefreshing) {
|
||||||
binding.tvChatEmptyMessage.isVisible = false
|
binding.tvChatEmptyMessage.isVisible = false
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import com.google.gson.annotations.SerializedName
|
|||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
data class CreateDmChatRoomRequest(
|
data class CreateDmChatRoomRequest(
|
||||||
@SerializedName("creatorId") val creatorId: Long
|
@SerializedName("recipientId") val recipientId: Long? = null,
|
||||||
|
@SerializedName("creatorId") val creatorId: Long? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class DmChatRepository(
|
|||||||
request = CreateDmChatRoomRequest(creatorId = creatorId)
|
request = CreateDmChatRoomRequest(creatorId = creatorId)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun createOrGetRoomByRecipientId(
|
||||||
|
token: String,
|
||||||
|
recipientId: Long
|
||||||
|
): Single<ApiResponse<CreateDmChatRoomResponse>> = api.createDmChatRoom(
|
||||||
|
authHeader = bearer(token),
|
||||||
|
request = CreateDmChatRoomRequest(recipientId = recipientId)
|
||||||
|
)
|
||||||
|
|
||||||
fun openRoom(
|
fun openRoom(
|
||||||
token: String,
|
token: String,
|
||||||
roomId: Long,
|
roomId: Long,
|
||||||
|
|||||||
+36
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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:src="@drawable/ic_bar_bell"
|
||||||
tools:visibility="visible" />
|
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
|
<ImageView
|
||||||
android:id="@+id/iv_more"
|
android:id="@+id/iv_more"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
|
|||||||
@@ -53,43 +53,53 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<ImageView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/iv_creator_channel_community_detail_profile"
|
android:id="@+id/layout_creator_channel_community_detail_creator"
|
||||||
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_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
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_constraintEnd_toStartOf="@id/btn_creator_channel_community_detail_more"
|
||||||
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_detail_profile"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
tools:text="크리에이터" />
|
|
||||||
|
|
||||||
<TextView
|
<ImageView
|
||||||
android:id="@+id/tv_creator_channel_community_detail_time"
|
android:id="@+id/iv_creator_channel_community_detail_profile"
|
||||||
style="@style/Typography.Body6"
|
android:layout_width="42dp"
|
||||||
android:layout_width="0dp"
|
android:layout_height="42dp"
|
||||||
android:layout_height="wrap_content"
|
android:contentDescription="@string/a11y_feed_profile_image"
|
||||||
android:layout_marginTop="2dp"
|
android:scaleType="centerCrop"
|
||||||
android:includeFontPadding="false"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:textColor="@color/gray_500"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_community_detail_nickname"
|
tools:src="@drawable/bg_placeholder" />
|
||||||
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_community_detail_nickname"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_detail_nickname"
|
<TextView
|
||||||
tools:text="2분 전" />
|
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
|
<ImageButton
|
||||||
android:id="@+id/btn_creator_channel_community_detail_more"
|
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:layout_marginBottom="12dp"
|
||||||
android:background="@drawable/bg_bottom_sheet_handle" />
|
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
|
<TextView
|
||||||
android:id="@+id/tv_user_block"
|
android:id="@+id/tv_user_block"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -58,4 +58,17 @@
|
|||||||
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
|
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</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>
|
</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>
|
||||||
@@ -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_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_title">Search recipient</string>
|
||||||
<string name="screen_message_recipient_search_hint">Search</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_loading">oading live broadcast…</string>
|
||||||
<string name="screen_live_now_title_prefix">Live</string>
|
<string name="screen_live_now_title_prefix">Live</string>
|
||||||
<string name="screen_live_now_title_suffix">now</string>
|
<string name="screen_live_now_title_suffix">now</string>
|
||||||
|
|||||||
@@ -550,6 +550,13 @@
|
|||||||
<string name="screen_voice_message_write_permission_denied">音声メッセージを利用するには録音権限が必要です。</string>
|
<string name="screen_voice_message_write_permission_denied">音声メッセージを利用するには録音権限が必要です。</string>
|
||||||
<string name="screen_message_recipient_title">受信者検索</string>
|
<string name="screen_message_recipient_title">受信者検索</string>
|
||||||
<string name="screen_message_recipient_search_hint">検索</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_loading">ライブを読み込み中。</string>
|
||||||
<string name="screen_live_now_title_prefix">今</string>
|
<string name="screen_live_now_title_prefix">今</string>
|
||||||
<string name="screen_live_now_title_suffix">配信中</string>
|
<string name="screen_live_now_title_suffix">配信中</string>
|
||||||
|
|||||||
@@ -549,6 +549,13 @@
|
|||||||
<string name="screen_voice_message_write_permission_denied">오디오 녹음 권한을 거부하시면 음성 메시지를 이용하실 수 없습니다.</string>
|
<string name="screen_voice_message_write_permission_denied">오디오 녹음 권한을 거부하시면 음성 메시지를 이용하실 수 없습니다.</string>
|
||||||
<string name="screen_message_recipient_title">받는 사람 검색</string>
|
<string name="screen_message_recipient_title">받는 사람 검색</string>
|
||||||
<string name="screen_message_recipient_search_hint">검색</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_loading">라이브를 불러오고 있습니다.</string>
|
||||||
<string name="screen_live_now_title_prefix">지금</string>
|
<string name="screen_live_now_title_prefix">지금</string>
|
||||||
<string name="screen_live_now_title_suffix">라이브중</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -55,6 +55,7 @@ class CreatorChannelHomeViewModelTest {
|
|||||||
setImmediateRxSchedulers()
|
setImmediateRxSchedulers()
|
||||||
SharedPreferenceManager.resetForTest()
|
SharedPreferenceManager.resetForTest()
|
||||||
SharedPreferenceManager.init(context)
|
SharedPreferenceManager.init(context)
|
||||||
|
SharedPreferenceManager.resetCanForTest()
|
||||||
SharedPreferenceManager.token = "test-token"
|
SharedPreferenceManager.token = "test-token"
|
||||||
repository = mock()
|
repository = mock()
|
||||||
viewModel = CreatorChannelHomeViewModel(repository)
|
viewModel = CreatorChannelHomeViewModel(repository)
|
||||||
@@ -64,6 +65,7 @@ class CreatorChannelHomeViewModelTest {
|
|||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
RxJavaPlugins.reset()
|
RxJavaPlugins.reset()
|
||||||
RxAndroidPlugins.reset()
|
RxAndroidPlugins.reset()
|
||||||
|
SharedPreferenceManager.resetCanForTest()
|
||||||
SharedPreferenceManager.resetForTest()
|
SharedPreferenceManager.resetForTest()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +277,7 @@ class CreatorChannelHomeViewModelTest {
|
|||||||
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
|
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
|
||||||
Single.just(ApiResponse(true, Any(), null))
|
Single.just(ApiResponse(true, Any(), null))
|
||||||
)
|
)
|
||||||
SharedPreferenceManager.can = 200
|
SharedPreferenceManager.setCanForTest(200)
|
||||||
viewModel.loadHome(100L)
|
viewModel.loadHome(100L)
|
||||||
|
|
||||||
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
|
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
|
||||||
@@ -291,7 +293,7 @@ class CreatorChannelHomeViewModelTest {
|
|||||||
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
|
whenever(repository.postChannelDonation(100L, 50, true, "응원", "Bearer test-token")).thenReturn(
|
||||||
Single.just(ApiResponse(true, Any(), null))
|
Single.just(ApiResponse(true, Any(), null))
|
||||||
)
|
)
|
||||||
SharedPreferenceManager.can = 30
|
SharedPreferenceManager.setCanForTest(30)
|
||||||
viewModel.loadHome(100L)
|
viewModel.loadHome(100L)
|
||||||
|
|
||||||
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
|
viewModel.postChannelDonation(can = 50, isSecret = true, message = "응원")
|
||||||
|
|||||||
+67
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -125,6 +125,20 @@ class CreatorChannelCommunityDetailContractTest {
|
|||||||
assertEquals(CreatorChannelCommunityRepliesResponse(listOf(reply), 0, 20, false).replies.first(), reply)
|
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 {
|
private fun projectFile(path: String): File {
|
||||||
var current = File(checkNotNull(System.getProperty("user.dir"))).absoluteFile
|
var current = File(checkNotNull(System.getProperty("user.dir"))).absoluteFile
|
||||||
while (current.parentFile != null && !File(current, "settings.gradle").exists()) {
|
while (current.parentFile != null && !File(current, "settings.gradle").exists()) {
|
||||||
|
|||||||
+4
-2
@@ -43,9 +43,10 @@ class CreatorChannelDonationViewModelTest {
|
|||||||
setImmediateRxSchedulers()
|
setImmediateRxSchedulers()
|
||||||
SharedPreferenceManager.resetForTest()
|
SharedPreferenceManager.resetForTest()
|
||||||
SharedPreferenceManager.init(context)
|
SharedPreferenceManager.init(context)
|
||||||
|
SharedPreferenceManager.resetCanForTest()
|
||||||
SodaLiveApplicationHolder.init(context as Application)
|
SodaLiveApplicationHolder.init(context as Application)
|
||||||
SharedPreferenceManager.token = "test-token"
|
SharedPreferenceManager.token = "test-token"
|
||||||
SharedPreferenceManager.can = 200
|
SharedPreferenceManager.setCanForTest(200)
|
||||||
repository = org.mockito.kotlin.mock()
|
repository = org.mockito.kotlin.mock()
|
||||||
viewModel = CreatorChannelDonationViewModel(repository)
|
viewModel = CreatorChannelDonationViewModel(repository)
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@ class CreatorChannelDonationViewModelTest {
|
|||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
RxJavaPlugins.reset()
|
RxJavaPlugins.reset()
|
||||||
RxAndroidPlugins.reset()
|
RxAndroidPlugins.reset()
|
||||||
|
SharedPreferenceManager.resetCanForTest()
|
||||||
SharedPreferenceManager.resetForTest()
|
SharedPreferenceManager.resetForTest()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ class CreatorChannelDonationViewModelTest {
|
|||||||
)
|
)
|
||||||
viewModel.loadDonations(100L, isOwner = false)
|
viewModel.loadDonations(100L, isOwner = false)
|
||||||
|
|
||||||
SharedPreferenceManager.can = 30
|
SharedPreferenceManager.setCanForTest(30)
|
||||||
viewModel.postChannelDonation(can = 250, isSecret = false, message = "응원")
|
viewModel.postChannelDonation(can = 250, isSecret = false, message = "응원")
|
||||||
|
|
||||||
verify(repository).postChannelDonation(100L, 250, false, "응원", "Bearer test-token")
|
verify(repository).postChannelDonation(100L, 250, false, "응원", "Bearer test-token")
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class MainV2ActivitySourceTest {
|
|||||||
val observerSource = source.substringFrom("private fun setupLiveEntryObservers()")
|
val observerSource = source.substringFrom("private fun setupLiveEntryObservers()")
|
||||||
val updateSource = source.substringFrom("private fun updateRouteLoadingDialog()")
|
val updateSource = source.substringFrom("private fun updateRouteLoadingDialog()")
|
||||||
val setupViewSource = source.substringFrom("override fun setupView()")
|
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("import kr.co.vividnext.sodalive.common.LoadingDialog"))
|
||||||
assertTrue(source.contains("private lateinit var routeLoadingDialog: LoadingDialog"))
|
assertTrue(source.contains("private lateinit var routeLoadingDialog: LoadingDialog"))
|
||||||
@@ -104,7 +105,7 @@ class MainV2ActivitySourceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertBefore(onCreateSource, "isDeferredRouteLoading =", "handler.postDelayed")
|
assertBefore(onCreateSource, "isDeferredRouteLoading =", "handler.postDelayed")
|
||||||
assertTrue(onCreateSource.contains("isDeferredRouteLoading = false"))
|
assertTrue(deferredRouteSource.contains("isDeferredRouteLoading = false"))
|
||||||
assertTrue(updateSource.contains("if (isDeferredRouteLoading || isLiveEntryLoading)"))
|
assertTrue(updateSource.contains("if (isDeferredRouteLoading || isLiveEntryLoading)"))
|
||||||
assertTrue(updateSource.contains("routeLoadingDialog.show(screenWidth)"))
|
assertTrue(updateSource.contains("routeLoadingDialog.show(screenWidth)"))
|
||||||
assertFalse(updateSource.contains("R.string.screen_live_loading"))
|
assertFalse(updateSource.contains("R.string.screen_live_loading"))
|
||||||
@@ -112,6 +113,61 @@ class MainV2ActivitySourceTest {
|
|||||||
assertTrue(setupViewSource.contains("setupLiveEntryObservers()"))
|
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
|
@Test
|
||||||
fun `MainV2Activity는 Community post를 creator fallback보다 먼저 Action으로 라우팅한다`() {
|
fun `MainV2Activity는 Community post를 creator fallback보다 먼저 Action으로 라우팅한다`() {
|
||||||
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
|
val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
|
||||||
|
|||||||
+4
-2
@@ -30,8 +30,10 @@ class MainV2AudioNotificationRouteSourceTest {
|
|||||||
assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
|
assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
|
||||||
|
|
||||||
val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)")
|
val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)")
|
||||||
assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "isV2AccessAllowed(AccessRequirement.Login)")
|
val deferredRouteSource = source.substringFrom("private val deferredRouteRunnable = Runnable")
|
||||||
assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
|
assertTrue(onCreateSource.contains("handler.postDelayed(deferredRouteRunnable, 1000)"))
|
||||||
|
assertBefore(deferredRouteSource, "handleAudioNotificationRoute(intent)", "isV2AccessAllowed(AccessRequirement.Login)")
|
||||||
|
assertBefore(deferredRouteSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-4
@@ -68,10 +68,8 @@ class ChatMainFragmentLayoutTest {
|
|||||||
assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)"))
|
assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)"))
|
||||||
assertTrue(source.contains("tvTitleBarTitle.setText(R.string.tab_chat)"))
|
assertTrue(source.contains("tvTitleBarTitle.setText(R.string.tab_chat)"))
|
||||||
assertTrue(source.contains("ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)"))
|
assertTrue(source.contains("ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)"))
|
||||||
assertTrue(source.contains("llTitleBarActions.addView"))
|
assertFalse(source.contains("setImageResource(R.drawable.ic_bar_search)"))
|
||||||
assertTrue(source.contains("setImageResource(R.drawable.ic_bar_search)"))
|
assertFalse(source.contains("SearchActivity::class.java"))
|
||||||
assertTrue(source.contains("LinearLayout.LayoutParams"))
|
|
||||||
assertTrue(source.contains("marginStart = resources.getDimensionPixelSize(R.dimen.spacing_14)"))
|
|
||||||
assertTrue(source.contains("setMenus("))
|
assertTrue(source.contains("setMenus("))
|
||||||
assertTrue(source.contains("R.string.screen_chat_filter_all"))
|
assertTrue(source.contains("R.string.screen_chat_filter_all"))
|
||||||
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
|
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
|
||||||
@@ -110,6 +108,28 @@ class ChatMainFragmentLayoutTest {
|
|||||||
assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false"))
|
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
|
@Test
|
||||||
fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() {
|
fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() {
|
||||||
val source = chatMainFragmentSource()
|
val source = chatMainFragmentSource()
|
||||||
|
|||||||
+5
-3
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat
|
package kr.co.vividnext.sodalive.v2.main.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -7,16 +8,17 @@ import java.io.File
|
|||||||
class ChatMainTitleBarActionSourceTest {
|
class ChatMainTitleBarActionSourceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `대화 title bar 우측 아이콘은 기존 목적지로 이동한다`() {
|
fun `대화 title bar 우측 아이콘은 캔 충전으로 이동한다`() {
|
||||||
val source = projectFile(
|
val source = projectFile(
|
||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
|
||||||
).readText()
|
).readText()
|
||||||
|
|
||||||
assertTrue(source.contains("ivTitleBarMenu.setOnClickListener"))
|
assertTrue(source.contains("ivTitleBarMenu.setOnClickListener"))
|
||||||
assertTrue(source.contains("searchAction.setOnClickListener"))
|
|
||||||
assertTrue(source.contains("ensureV2Access"))
|
assertTrue(source.contains("ensureV2Access"))
|
||||||
assertTrue(source.contains("CanChargeActivity::class.java"))
|
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 {
|
private fun projectFile(relativePath: String): File {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
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 io.reactivex.rxjava3.core.Single
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
|
||||||
@@ -27,6 +29,7 @@ import org.junit.Test
|
|||||||
|
|
||||||
class DmChatRepositoryTest {
|
class DmChatRepositoryTest {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
private val api = FakeDmChatApi()
|
private val api = FakeDmChatApi()
|
||||||
private val socketFactory = FakeWebSocketFactory()
|
private val socketFactory = FakeWebSocketFactory()
|
||||||
private val socketClient = DmChatSocketClient(
|
private val socketClient = DmChatSocketClient(
|
||||||
@@ -45,6 +48,32 @@ class DmChatRepositoryTest {
|
|||||||
assertEquals(CreateDmChatRoomRequest(creatorId = 11L), api.lastCreateRequest)
|
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
|
@Test
|
||||||
fun `openRoom은 기본 limit 20과 bearer header를 API에 위임한다`() {
|
fun `openRoom은 기본 limit 20과 bearer header를 API에 위임한다`() {
|
||||||
repository.openRoom(token = "test-token", roomId = 12L).blockingGet()
|
repository.openRoom(token = "test-token", roomId = 12L).blockingGet()
|
||||||
|
|||||||
+161
@@ -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" }
|
||||||
|
}
|
||||||
+215
@@ -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.
|
||||||
|
- 남은 항목: 없음.
|
||||||
+53
@@ -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건.
|
||||||
Reference in New Issue
Block a user