diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 82f7bf95..f4cf935a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -123,6 +123,9 @@
+
(
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml b/app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml
new file mode 100644
index 00000000..97fcda1a
--- /dev/null
+++ b/app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailUiContractTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailUiContractTest.kt
new file mode 100644
index 00000000..ff461fc5
--- /dev/null
+++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailUiContractTest.kt
@@ -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"))
+ 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("")
+ 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() }
+ }
+}