feat(creator-channel): FanTalk 상세 화면을 추가한다

This commit is contained in:
2026-07-09 07:44:06 +09:00
parent 04dcb3f09f
commit da26b65a73
5 changed files with 652 additions and 0 deletions

View File

@@ -123,6 +123,9 @@
<activity
android:name=".v2.creator.channel.fantalk.write.CreatorChannelFanTalkWriteActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name=".v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".v2.live.onair.HomeOnAirLiveActivity" />
<activity
android:name=".v2.main.chat.dm.DmChatRoomActivity"

View File

@@ -187,6 +187,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.write.CreatorChannelFanTalkWriteViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesViewModel
@@ -458,6 +459,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CreatorChannelCommunityDetailViewModel(get(), get(), get()) }
viewModel { CreatorChannelCommunityReplyViewModel(get(), get(), get()) }
viewModel { CreatorChannelFanTalkViewModel(get(), get()) }
viewModel { CreatorChannelFanTalkDetailViewModel(get(), get()) }
viewModel { CreatorChannelFanTalkWriteViewModel(get()) }
viewModel { CreatorChannelDonationViewModel(get(), get()) }
viewModel { PushNotificationListViewModel(get()) }

View File

@@ -0,0 +1,238 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.Toast
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelFantalkDetailBinding
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelFanTalkDetailActivity : BaseActivity<ActivityCreatorChannelFantalkDetailBinding>(
ActivityCreatorChannelFantalkDetailBinding::inflate
) {
private val viewModel: CreatorChannelFanTalkDetailViewModel by viewModel()
private var creatorId: Long = 0L
private var replyMorePopup: PopupWindow? = null
private var renderedEditingTarget: CreatorChannelFanTalkDetailEditTarget? = null
override fun setupView() {
creatorId = intent.getLongExtra(EXTRA_CREATOR_ID, 0L)
val payload = payloadFromIntent() ?: run {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
if (creatorId <= 0L || payload.fanTalkId <= 0L) {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
finish()
return
}
binding.btnCreatorChannelFantalkDetailBack.setOnClickListener { finish() }
binding.etCreatorChannelFantalkDetailReply.movementMethod = ScrollingMovementMethod()
binding.etCreatorChannelFantalkDetailReply.doAfterTextChanged { editable ->
viewModel.updateReplyInput(editable?.toString().orEmpty())
}
binding.btnCreatorChannelFantalkDetailSend.setOnClickListener { viewModel.submitReply() }
binding.btnCreatorChannelFantalkDetailEditCancel.setOnClickListener { viewModel.cancelReplyEdit() }
viewModel.detailStateLiveData.observe(this) { state -> render(state) }
viewModel.replacementModalEventLiveData.observe(this) { event ->
event.consume()?.let(::showReplacementDialog)
}
viewModel.replyChangedEventLiveData.observe(this) { changed ->
if (changed == true) {
setResult(Activity.RESULT_OK)
hideReplyKeyboard()
viewModel.consumeReplyChangedEvent()
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let { toast ->
val message = toast.message ?: toast.resId?.let(::getString)
message?.let(::showToast)
}
}
viewModel.load(creatorId, payload)
}
override fun onDestroy() {
replyMorePopup?.dismiss()
replyMorePopup = null
super.onDestroy()
}
private fun render(state: CreatorChannelFanTalkDetailUiState) {
when (state) {
is CreatorChannelFanTalkDetailUiState.Content -> renderContent(state)
}
}
private fun renderContent(content: CreatorChannelFanTalkDetailUiState.Content) = with(binding) {
bindParentFanTalk(content.parentFanTalk)
bindReply(content.reply)
if (etCreatorChannelFantalkDetailReply.text.toString() != content.replyInput) {
etCreatorChannelFantalkDetailReply.setText(content.replyInput)
etCreatorChannelFantalkDetailReply.setSelection(content.replyInput.length)
}
btnCreatorChannelFantalkDetailEditCancel.isVisible = content.editingTarget != null
if (content.editingTarget != null && content.editingTarget != renderedEditingTarget) {
showReplyKeyboard()
}
renderedEditingTarget = content.editingTarget
updateSendButton(content.isSendEnabled)
}
private fun bindParentFanTalk(item: CreatorChannelFanTalkUiModel) = with(binding) {
ivCreatorChannelFantalkDetailProfile.loadProfile(item.writerProfileImageUrl)
tvCreatorChannelFantalkDetailNickname.text = item.writerNickname
tvCreatorChannelFantalkDetailTime.text = item.createdAtText
tvCreatorChannelFantalkDetailContent.text = item.content
ivCreatorChannelFantalkDetailMore.isVisible = false
ivCreatorChannelFantalkDetailMore.setOnClickListener(null)
}
private fun bindReply(reply: CreatorChannelFanTalkReplyUiModel?) = with(binding) {
viewCreatorChannelFantalkDetailReplyConnector.isVisible = reply != null
layoutCreatorChannelFantalkDetailReply.isVisible = reply != null
if (reply == null) {
replyMorePopup?.dismiss()
return@with
}
ivCreatorChannelFantalkDetailReplyProfile.loadProfile(reply.writerProfileImageUrl)
tvCreatorChannelFantalkDetailReplyNickname.text = reply.writerNickname
tvCreatorChannelFantalkDetailReplyTime.text = reply.createdAtText
tvCreatorChannelFantalkDetailReplyContent.text = reply.content
ivCreatorChannelFantalkDetailReplyMore.isVisible = reply.fanTalkId > 0L
ivCreatorChannelFantalkDetailReplyMore.setOnClickListener(
if (reply.fanTalkId > 0L) {
View.OnClickListener { anchor -> showReplyMorePopup(anchor) }
} else {
null
}
)
}
private fun showReplyMorePopup(anchor: View) {
replyMorePopup?.dismiss()
val popupBinding = ViewCreatorChannelFantalkMorePopupBinding.inflate(LayoutInflater.from(anchor.context))
replyMorePopup = PopupWindow(anchor.context).apply {
contentView = popupBinding.root
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isOutsideTouchable = true
isFocusable = true
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
}
popupBinding.tvCreatorChannelFantalkMoreEdit.isVisible = true
popupBinding.tvCreatorChannelFantalkMoreDelete.isVisible = true
popupBinding.tvCreatorChannelFantalkMoreEdit.setOnClickListener {
replyMorePopup?.dismiss()
viewModel.startReplyEdit()
}
popupBinding.tvCreatorChannelFantalkMoreDelete.setOnClickListener {
replyMorePopup?.dismiss()
showDeleteReplyDialog()
}
replyMorePopup?.showAsDropDown(anchor)
}
private fun showReplacementDialog(modal: CreatorChannelFanTalkReplacementModalUiModel) {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(modal.titleResId),
desc = getString(modal.descriptionResId),
confirmButtonTitle = getString(modal.confirmResId),
confirmButtonClick = { viewModel.confirmReplacement() },
cancelButtonTitle = getString(modal.cancelResId),
cancelButtonClick = { viewModel.cancelReplacement() }
).show(screenWidth)
}
private fun showDeleteReplyDialog() {
V2ModalDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.confirm_delete_title),
desc = getString(R.string.confirm_delete_message),
confirmButtonTitle = getString(R.string.confirm),
confirmButtonClick = { viewModel.deleteReply() },
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelFantalkDetailSend) {
isEnabled = enabled
setBackgroundResource(
if (enabled) {
R.drawable.bg_creator_channel_community_send_enabled
} else {
R.drawable.bg_creator_channel_community_send_disabled
}
)
setImageResource(if (enabled) R.drawable.ic_new_arrow_up_white else R.drawable.ic_new_arrow_up_gray)
}
private fun showReplyKeyboard() {
val editText = binding.etCreatorChannelFantalkDetailReply
editText.requestFocus()
editText.post {
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun hideReplyKeyboard() {
val editText = binding.etCreatorChannelFantalkDetailReply
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(editText.windowToken, 0)
editText.clearFocus()
}
private fun payloadFromIntent(): CreatorChannelFanTalkDetailPayload? {
return IntentCompat.getParcelableExtra(intent, EXTRA_PAYLOAD, CreatorChannelFanTalkDetailPayload::class.java)
}
private fun ImageView.loadProfile(url: String) {
loadUrl(url) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
companion object {
private const val EXTRA_CREATOR_ID = "extra_creator_id"
private const val EXTRA_PAYLOAD = "extra_payload"
fun newIntent(context: Context, creatorId: Long, payload: CreatorChannelFanTalkDetailPayload): Intent {
return Intent(context, CreatorChannelFanTalkDetailActivity::class.java)
.putExtra(EXTRA_CREATOR_ID, creatorId)
.putExtra(EXTRA_PAYLOAD, payload)
}
}
}

View File

@@ -0,0 +1,249 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_fantalk_detail_title_bar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_back"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/a11y_back"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_creator_channel_fantalk_detail"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintBottom_toTopOf="@id/layout_creator_channel_fantalk_detail_input_bar"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_fantalk_detail_title_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_14">
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_placeholder_profile" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_nickname"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_14"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/iv_creator_channel_fantalk_detail_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_fantalk_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_fantalk_detail_profile"
tools:text="닉네임" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_fantalk_detail_nickname"
tools:text="2분 전" />
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_more"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_creator_channel_fantalk_detail_nickname"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_content"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:includeFontPadding="false"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_nickname"
app:layout_constraintTop_toBottomOf="@id/iv_creator_channel_fantalk_detail_profile"
tools:text="팬톡 응원 메시지 내용입니다." />
<View
android:id="@+id/view_creator_channel_fantalk_detail_reply_connector"
android:layout_width="1dp"
android:layout_height="@dimen/spacing_14"
android:layout_marginTop="@dimen/spacing_14"
android:background="@color/gray_800"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_content"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_fantalk_detail_content"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/layout_creator_channel_fantalk_detail_reply"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_fantalk_reply"
android:orientation="vertical"
android:padding="@dimen/spacing_14"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_creator_channel_fantalk_detail_content"
app:layout_constraintTop_toBottomOf="@id/view_creator_channel_fantalk_detail_reply_connector"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_reply_profile"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/a11y_feed_profile_image"
android:scaleType="centerCrop"
tools:src="@drawable/ic_placeholder_profile" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_nickname"
style="@style/Typography.Caption3"
android:layout_width="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"
tools:text="크리에이터" />
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_time"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="방금 전" />
<View
android:id="@+id/view_creator_channel_fantalk_detail_reply_header_spacer"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/iv_creator_channel_fantalk_detail_reply_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/spacing_8"
android:contentDescription="@null"
android:src="@drawable/ic_new_more" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_channel_fantalk_detail_reply_content"
style="@style/Typography.Body6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="응원 고마워요!" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
<LinearLayout
android:id="@+id/layout_creator_channel_fantalk_detail_input_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingVertical="10dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/et_creator_channel_fantalk_detail_reply"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_creator_channel_community_input_field"
android:hint="@string/creator_channel_community_reply_placeholder"
android:importantForAutofill="no"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="3"
android:minHeight="40dp"
android:minLines="1"
android:paddingHorizontal="@dimen/spacing_14"
android:scrollbars="vertical"
android:textColor="@color/white"
android:textColorHint="@color/gray_500"
tools:ignore="LabelFor" />
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_send"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_send_disabled"
android:contentDescription="@string/action_send"
android:enabled="false"
android:src="@drawable/ic_new_arrow_up_gray" />
<ImageButton
android:id="@+id/btn_creator_channel_fantalk_detail_edit_cancel"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@android:color/transparent"
android:contentDescription="@string/cancel"
android:src="@drawable/ic_x_white"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,160 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class CreatorChannelFanTalkDetailUiContractTest {
@Test
fun `상세 Activity source는 payload intent와 ViewModel 이벤트를 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailActivity.kt"
).readText()
assertTrue(source.contains("BaseActivity<ActivityCreatorChannelFantalkDetailBinding>"))
assertTrue(source.contains("private val viewModel: CreatorChannelFanTalkDetailViewModel by viewModel()"))
assertTrue(
source.contains(
"fun newIntent(context: Context, creatorId: Long, " +
"payload: CreatorChannelFanTalkDetailPayload): Intent"
)
)
assertTrue(source.contains("putExtra(EXTRA_PAYLOAD, payload)"))
assertTrue(source.contains("IntentCompat.getParcelableExtra"))
assertTrue(source.contains("if (creatorId <= 0L || payload.fanTalkId <= 0L)"))
assertTrue(source.contains("R.string.creator_channel_community_invalid_post"))
assertTrue(source.contains("viewModel.load(creatorId, payload)"))
assertTrue(source.contains("doAfterTextChanged"))
assertTrue(source.contains("viewModel.updateReplyInput"))
assertTrue(source.contains("viewModel.submitReply()"))
assertTrue(source.contains("viewModel.cancelReplyEdit()"))
assertTrue(source.contains("viewModel.replacementModalEventLiveData.observe"))
assertTrue(source.contains("viewModel.replyChangedEventLiveData.observe"))
assertTrue(source.contains("setResult(Activity.RESULT_OK)"))
assertTrue(source.contains("viewModel.consumeReplyChangedEvent()"))
assertTrue(source.contains("viewModel.startReplyEdit()"))
assertTrue(source.contains("showDeleteReplyDialog()"))
assertTrue(source.contains("ivCreatorChannelFantalkDetailReplyMore.isVisible = reply.fanTalkId > 0L"))
assertTrue(source.contains("V2ModalDialog("))
assertTrue(source.contains("getString(modal.titleResId)"))
assertTrue(source.contains("getString(modal.descriptionResId)"))
assertTrue(source.contains("getString(modal.confirmResId)"))
assertTrue(source.contains("getString(modal.cancelResId)"))
assertTrue(source.contains("R.drawable.bg_creator_channel_community_send_enabled"))
assertTrue(source.contains("R.drawable.bg_creator_channel_community_send_disabled"))
assertTrue(source.contains("R.drawable.ic_new_arrow_up_white"))
assertTrue(source.contains("R.drawable.ic_new_arrow_up_gray"))
}
@Test
fun `상세 layout은 커뮤니티 답글 구조와 FanTalk 답글 card 토큰을 사용한다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml").readText()
assertTrue(layout.contains("""android:background="@color/black""""))
assertTrue(layout.contains("""android:id="@+id/btn_creator_channel_fantalk_detail_back""""))
assertTrue(layout.contains("""android:layout_height="60dp""""))
assertTrue(layout.contains("""android:id="@+id/iv_creator_channel_fantalk_detail_profile""""))
assertTrue(layout.contains("""android:layout_width="42dp""""))
assertTrue(layout.contains("""android:id="@+id/tv_creator_channel_fantalk_detail_nickname""""))
assertTrue(layout.contains("""android:id="@+id/tv_creator_channel_fantalk_detail_content""""))
assertTrue(layout.contains("""android:id="@+id/layout_creator_channel_fantalk_detail_reply""""))
assertTrue(layout.contains("@drawable/bg_creator_channel_fantalk_reply"))
assertTrue(layout.contains("""android:id="@+id/iv_creator_channel_fantalk_detail_reply_profile""""))
assertTrue(layout.contains("""android:layout_width="20dp""""))
assertTextViewUsesStyle(
layout,
"tv_creator_channel_fantalk_detail_reply_nickname",
"@style/Typography.Caption3"
)
assertTextViewUsesStyle(
layout,
"tv_creator_channel_fantalk_detail_reply_time",
"@style/Typography.Caption3"
)
assertTextViewContains(
layout,
"tv_creator_channel_fantalk_detail_reply_nickname",
"""android:layout_width="wrap_content"""
)
assertTextViewContains(
layout,
"tv_creator_channel_fantalk_detail_reply_time",
"""android:layout_marginStart="@dimen/spacing_4"""
)
assertTrue(layout.contains("""android:id="@+id/iv_creator_channel_fantalk_detail_reply_more""""))
assertTrue(layout.contains("""android:id="@+id/layout_creator_channel_fantalk_detail_input_bar""""))
assertTrue(layout.contains("view_creator_channel_fantalk_detail_reply_header_spacer"))
assertTrue(layout.contains("android:layout_width=\"0dp\""))
assertTrue(layout.contains("android:layout_weight=\"1\""))
assertTrue(layout.contains("@drawable/bg_creator_channel_community_input_field"))
assertTrue(layout.contains("@string/creator_channel_community_reply_placeholder"))
assertTrue(layout.contains("""android:maxLines="3""""))
assertTrue(layout.contains("@drawable/bg_creator_channel_community_send_disabled"))
assertTrue(layout.contains("@drawable/ic_new_arrow_up_gray"))
}
@Test
fun `상세 Manifest DI ViewModel source 계약을 등록한다`() {
val manifest = projectFile("app/src/main/AndroidManifest.xml").readText()
val appDi = projectFile("app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt").readText()
val viewModel = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailViewModel.kt"
).readText()
assertTrue(manifest.contains(".v2.creator.channel.fantalk.detail.CreatorChannelFanTalkDetailActivity"))
assertTrue(manifest.contains("""android:windowSoftInputMode="stateAlwaysHidden|adjustResize""""))
assertTrue(
appDi.contains(
"import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail." +
"CreatorChannelFanTalkDetailViewModel"
)
)
assertTrue(appDi.contains("viewModel { CreatorChannelFanTalkDetailViewModel(get(), get()) }"))
assertTrue(viewModel.contains("fun cancelReplyEdit()"))
assertTrue(viewModel.contains("fun consumeReplyChangedEvent()"))
}
@Test
fun `답글 대체 모달 문자열은 리소스 id와 다국어 strings로 제공한다`() {
val uiModels = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailUiModels.kt"
).readText()
val koStrings = projectFile("app/src/main/res/values/strings.xml").readText()
val enStrings = projectFile("app/src/main/res/values-en/strings.xml").readText()
val jaStrings = projectFile("app/src/main/res/values-ja/strings.xml").readText()
assertTrue(uiModels.contains("R.string.screen_user_profile_reply_edit"))
assertTrue(uiModels.contains("R.string.creator_channel_fantalk_reply_replace_description"))
assertTrue(uiModels.contains("R.string.cancel"))
assertTrue(uiModels.contains("R.string.confirm"))
assertFalse(uiModels.contains("기존 답글이 신규 답글로 대체됩니다"))
assertTrue(koStrings.contains("creator_channel_fantalk_reply_replace_description"))
assertTrue(enStrings.contains("creator_channel_fantalk_reply_replace_description"))
assertTrue(jaStrings.contains("creator_channel_fantalk_reply_replace_description"))
assertEquals(R.string.screen_user_profile_reply_edit, CreatorChannelFanTalkReplacementModalUiModel().titleResId)
}
private fun assertTextViewUsesStyle(layout: String, id: String, style: String) {
val textViewPattern = Regex("<TextView[\\s\\S]*?android:id=\"@\\+id/$id\"[\\s\\S]*?style=\"$style\"")
assertTrue("Expected $id to use $style", textViewPattern.containsMatchIn(layout))
}
private fun assertTextViewContains(layout: String, id: String, expected: String) {
val textViewPattern = Regex("<TextView[\\s\\S]*?android:id=\"@\\+id/$id\"[\\s\\S]*?/>")
val textView = textViewPattern.find(layout)?.value.orEmpty()
assertTrue("Expected $id to contain $expected", textView.contains(expected))
}
private fun projectFile(path: String): File = File(projectRoot(), path)
private fun projectRoot(): File {
return generateSequence(File(System.getProperty("user.dir") ?: ".").absoluteFile) { it.parentFile }
.first { File(it, "settings.gradle").exists() }
}
}