From da26b65a73b1742ec8d609e0d6eb21ddd15eee2b Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 07:44:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 + .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 + .../CreatorChannelFanTalkDetailActivity.kt | 238 +++++++++++++++++ ...ctivity_creator_channel_fantalk_detail.xml | 249 ++++++++++++++++++ ...eatorChannelFanTalkDetailUiContractTest.kt | 160 +++++++++++ 5 files changed, 652 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailActivity.kt create mode 100644 app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailUiContractTest.kt 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() } + } +}