diff --git a/app/build.gradle b/app/build.gradle index 1991dad8..70a69331 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,7 +35,7 @@ android { applicationId "kr.co.vividnext.sodalive" minSdk 23 targetSdk 34 - versionCode 188 + versionCode 189 versionName "1.42.1" } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/Character.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/Character.kt index 3874152f..ad403a8b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/Character.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/Character.kt @@ -1,12 +1,15 @@ package kr.co.vividnext.sodalive.chat.character +import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +@Parcelize @Keep data class Character( @SerializedName("characterId") val characterId: Long, @SerializedName("name") val name: String, @SerializedName("description") val description: String, @SerializedName("imageUrl") val imageUrl: String -) +) : Parcelable diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt index d17277ff..2a8b5d9b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt @@ -20,12 +20,4 @@ interface OriginalWorkApi { @Header("Authorization") authHeader: String, @Path("id") id: Long ): Single> - - @GET("/api/chat/original/{id}/characters") - fun getOriginalWorkCharacters( - @Header("Authorization") authHeader: String, - @Path("id") id: Long, - @Query("page") page: Int, - @Query("size") size: Int - ): Single> } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt index bc033e22..b6707093 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.chat.original +import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize import kr.co.vividnext.sodalive.chat.character.Character +@Parcelize @Keep data class OriginalWorkDetailResponse( @SerializedName("imageUrl") val imageUrl: String?, @@ -12,6 +15,11 @@ data class OriginalWorkDetailResponse( @SerializedName("category") val category: String, @SerializedName("isAdult") val isAdult: Boolean, @SerializedName("description") val description: String, + @SerializedName("originalWork") val originalWork: String?, @SerializedName("originalLink") val originalLink: String?, + @SerializedName("writer") val writer: String?, + @SerializedName("studio") val studio: String?, + @SerializedName("originalLinks") val originalLinks: List, + @SerializedName("tags") val tags: List, @SerializedName("characters") val characters: List -) +) : Parcelable diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt index e085e48c..e8498bd7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -20,13 +20,4 @@ class OriginalWorkRepository( ): Single> { return api.getOriginalWorkDetail(token, id) } - - fun getOriginalCharacters( - token: String, - id: Long, - page: Int, - size: Int - ): Single> { - return api.getOriginalWorkCharacters(token, id, page, size) - } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkCharacterFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkCharacterFragment.kt new file mode 100644 index 00000000..b6fe6248 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkCharacterFragment.kt @@ -0,0 +1,77 @@ +package kr.co.vividnext.sodalive.chat.original.detail + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity +import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID +import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration +import kr.co.vividnext.sodalive.databinding.FragmentOriginalWorkCharacterBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class OriginalWorkCharacterFragment : BaseFragment( + FragmentOriginalWorkCharacterBinding::inflate +) { + private var originalWorkDetailResponse: OriginalWorkDetailResponse? = null + + private lateinit var adapter: OriginalWorkDetailAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (arguments != null) { + originalWorkDetailResponse = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requireArguments().getParcelable( + OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL, + OriginalWorkDetailResponse::class.java + ) + } else { + requireArguments().getParcelable( + OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (originalWorkDetailResponse != null) { + setupRecycler() + adapter.setItems(originalWorkDetailResponse!!.characters) + } + } + + private fun setupRecycler() { + adapter = OriginalWorkDetailAdapter( + onClickCharacter = { characterId -> + startActivity( + Intent( + requireContext(), + CharacterDetailActivity::class.java + ).apply { + putExtra(EXTRA_CHARACTER_ID, characterId) + } + ) + } + ) + + val spanCount = 2 + val spacingPx = 16f.dpToPx().toInt() + binding.rvCharacter.layoutManager = GridLayoutManager(requireContext(), spanCount) + binding.rvCharacter.addItemDecoration( + GridSpacingItemDecoration( + spanCount, + spacingPx, + true + ) + ) + + binding.rvCharacter.adapter = adapter + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt index 7c903f85..50aa9bf3 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt @@ -1,18 +1,16 @@ package kr.co.vividnext.sodalive.chat.original.detail -import android.content.Intent import android.os.Bundle -import androidx.core.net.toUri -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView +import android.view.View +import android.widget.Toast import coil.load import coil.size.Scale import coil.transform.BlurTransformation +import coil.transform.RoundedCornersTransformation +import com.google.android.material.tabs.TabLayout import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseActivity -import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity -import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID -import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration +import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.databinding.ActivityOriginalWorkDetailBinding import kr.co.vividnext.sodalive.extensions.dpToPx import org.koin.android.ext.android.inject @@ -23,25 +21,29 @@ class OriginalWorkDetailActivity : BaseActivity 0) viewModel.loadDetail(originalId) + viewModel.loadDetail(originalId) } override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + // 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:432) binding.ivBg.post { val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels @@ -53,65 +55,106 @@ class OriginalWorkDetailActivity : BaseActivity - startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) - }, - onClickCharacter = { characterId -> - startActivity( - Intent(this, CharacterDetailActivity::class.java).apply { - putExtra(EXTRA_CHARACTER_ID, characterId) - } - ) - } - ) - val spanCount = 2 - val spacingPx = 16f.dpToPx().toInt() - val layoutManager = GridLayoutManager(this, spanCount) - layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - return if (adapter.getItemViewType(position) == 0) spanCount else 1 - } - } - binding.rvDetail.layoutManager = layoutManager - binding.rvDetail.addItemDecoration(GridSpacingItemDecoration(spanCount, spacingPx, true, headerCount = 1)) - binding.rvDetail.adapter = adapter + private fun setupTabs() { + val tabs = binding.tabs + tabs.addTab(tabs.newTab().setText("캐릭터").setTag("character")) + tabs.addTab(tabs.newTab().setText("작품정보").setTag("info")) - binding.rvDetail.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if (!recyclerView.canScrollVertically(1)) { - if (originalId > 0) viewModel.loadMoreCharacters(originalId) - } + tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val tag = tab.tag as String + changeFragment(tag) } + + override fun onTabUnselected(tab: TabLayout.Tab) { + } + + override fun onTabReselected(tab: TabLayout.Tab) { + } + }) } + private fun changeFragment(tag: String) { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = when (tag) { + "info" -> OriginalWorkInfoFragment() + else -> OriginalWorkCharacterFragment() + } + + val bundle = Bundle() + bundle.putParcelable(EXTRA_ORIGINAL_WORK_DETAIL, viewModel.detailResponse) + fragment.arguments = bundle + + fragmentTransaction.replace(R.id.container, fragment, tag) + fragmentTransaction.setPrimaryNavigationFragment(fragment) + fragmentTransaction.setReorderingAllowed(true) + fragmentTransaction.commitNow() + } + private fun bind() { - viewModel.detail.observe(this) { data -> - adapter.setHeader(data) - // 배경 이미지 Blur 처리 및 채우기 - val imageUrl = data?.imageUrl - if (!imageUrl.isNullOrBlank()) { - binding.ivBg.load(imageUrl) { - transformations( - BlurTransformation( - this@OriginalWorkDetailActivity, - 25f, - 2.5f - ) - ) - scale(Scale.FILL) - } + viewModel.toast.observe(this) { + it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() } + } + + viewModel.isLoading.observe(this) { + if (it) { + loadingDialog.show(screenWidth, "") } else { - binding.ivBg.setImageResource(R.drawable.bg_placeholder) + loadingDialog.dismiss() } } - viewModel.characters.observe(this) { list -> - adapter.setItems(list) + + viewModel.detail.observe(this) { data -> + if (data != null) { + // 배경 이미지 Blur 처리 및 채우기 + val imageUrl = data.imageUrl + if (!imageUrl.isNullOrBlank()) { + binding.ivBg.load(imageUrl) { + transformations( + BlurTransformation( + this@OriginalWorkDetailActivity, + 25f, + 2.5f + ) + ) + scale(Scale.FILL) + } + } else { + binding.ivBg.setImageResource(R.drawable.bg_placeholder) + } + + binding.ivCover.load(data.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(16f.dpToPx())) + } + + binding.tvTitle.text = data.title + binding.tvContentType.text = data.contentType + binding.tvCategory.text = data.category + binding.tvTags.text = data.tags.joinToString(" ") { + if (it.startsWith("#")) { + it + } else { + "#$it" + } + } + + binding.tvAdult.visibility = if (data.isAdult) { + View.VISIBLE + } else { + View.GONE + } + + changeFragment("character") + } } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt index d3470861..6aee90fc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt @@ -1,128 +1,24 @@ package kr.co.vividnext.sodalive.chat.original.detail import android.annotation.SuppressLint -import android.text.TextUtils import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import coil.load import coil.transform.RoundedCornersTransformation +import com.orhanobut.logger.Logger import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.chat.character.Character -import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailCharacterBinding -import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailHeaderBinding import kr.co.vividnext.sodalive.extensions.dpToPx class OriginalWorkDetailAdapter( - private val onClickOpenLink: (String) -> Unit, + private var items: List = emptyList(), private val onClickCharacter: (Long) -> Unit -) : RecyclerView.Adapter() { - - // 작품소개 확장 상태 (헤더 1개이므로 어댑터 레벨에서 유지) - private var isDescriptionExpanded: Boolean = false - - companion object { - private const val TYPE_HEADER = 0 - private const val TYPE_ITEM = 1 - } - - private var header: OriginalWorkDetailResponse? = null - private val items = mutableListOf() - - fun setHeader(data: OriginalWorkDetailResponse?) { - header = data - notifyItemChanged(0) - } - - @SuppressLint("NotifyDataSetChanged") - fun setItems(chars: List) { - items.clear() - items.addAll(chars) - notifyDataSetChanged() - } - - override fun getItemViewType(position: Int): Int = if (position == 0) TYPE_HEADER else TYPE_ITEM - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == TYPE_HEADER) { - val binding = ItemOriginalDetailHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - HeaderVH(binding) - } else { - val binding = ItemOriginalDetailCharacterBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ItemVH(binding) - } - } - - override fun getItemCount(): Int = 1 + items.size - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is HeaderVH) { - holder.bind(header) - } else if (holder is ItemVH) { - holder.bind(items[position - 1]) - } - } - - inner class HeaderVH(private val binding: ItemOriginalDetailHeaderBinding) : - RecyclerView.ViewHolder(binding.root) { - private fun applyDescriptionState() { - if (isDescriptionExpanded) { - binding.tvDescription.maxLines = Int.MAX_VALUE - binding.tvDescription.ellipsize = null - } else { - binding.tvDescription.maxLines = 2 - binding.tvDescription.ellipsize = TextUtils.TruncateAt.END - } - } - - fun bind(data: OriginalWorkDetailResponse?) { - if (data == null) return - - // Cover small card - binding.ivCover.load(data.imageUrl) { - crossfade(true) - placeholder(R.drawable.bg_placeholder) - transformations(RoundedCornersTransformation(16f.dpToPx())) - } - - binding.tvTitle.text = data.title - binding.tvContentType.text = data.contentType - binding.tvCategory.text = data.category - binding.tvDescription.text = data.description - - binding.tvAdult.visibility = if (data.isAdult) { - View.VISIBLE - } else { - View.GONE - } - - // 설명 토글 (2줄/전체) - applyDescriptionState() - binding.tvDescription.setOnClickListener { - isDescriptionExpanded = !isDescriptionExpanded - applyDescriptionState() - } - - binding.tvOpenOriginal.isEnabled = !data.originalLink.isNullOrBlank() - binding.tvOpenOriginal.alpha = if (data.originalLink.isNullOrBlank()) 0.5f else 1f - binding.tvOpenOriginal.setOnClickListener { - data.originalLink?.let { onClickOpenLink(it) } - } - } - } - - inner class ItemVH(private val binding: ItemOriginalDetailCharacterBinding) : - RecyclerView.ViewHolder(binding.root) { +) : RecyclerView.Adapter() { + inner class ItemVH( + private val binding: ItemOriginalDetailCharacterBinding + ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Character) { binding.tvCharacterName.text = item.name binding.tvCharacterDescription.text = item.description @@ -134,4 +30,25 @@ class OriginalWorkDetailAdapter( binding.root.setOnClickListener { onClickCharacter(item.characterId) } } } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemVH( + ItemOriginalDetailCharacterBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + override fun onBindViewHolder(holder: ItemVH, position: Int) { + holder.bind(items[position]) + Logger.d("onBindViewHolder: $position") + } + + override fun getItemCount(): Int = items.size + + @SuppressLint("NotifyDataSetChanged") + fun setItems(chars: List) { + items = chars + notifyDataSetChanged() + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt index 22cc8b5f..a8b3f328 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt @@ -23,12 +23,7 @@ class OriginalWorkDetailViewModel( private val _detail = MutableLiveData(null) val detail: LiveData get() = _detail - private val _characters = MutableLiveData>(emptyList()) - val characters: LiveData> get() = _characters - - private val size = 20 - private var page = 1 // 초기 로딩 이후부터 사용하므로 1부터 시작 - private var isLast = false + lateinit var detailResponse: OriginalWorkDetailResponse fun loadDetail(id: Long) { if (_isLoading.value == true) return @@ -43,46 +38,8 @@ class OriginalWorkDetailViewModel( .subscribe({ response -> val data = response.data if (response.success && data != null) { - _detail.value = data - // 상세 응답 내 캐릭터를 초기 세팅 - _characters.value = data.characters - // 초기 캐릭터가 없으면 다음 로딩에서 page=1 그대로 시도 - page = 1 - isLast = false - } else { - _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다." - } - _isLoading.value = false - }, { e -> - _isLoading.value = false - _toast.value = e.message ?: "알 수 없는 오류가 발생했습니다." - }) - ) - } - - fun loadMoreCharacters(id: Long) { - if (_isLoading.value == true || isLast) return - _isLoading.value = true - compositeDisposable.add( - repository.getOriginalCharacters( - token = "Bearer ${SharedPreferenceManager.token}", - id = id, - page = page, - size = size - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response -> - val data = response.data - if (response.success && data != null) { - val current = _characters.value ?: emptyList() - val next = current + data.content - _characters.value = next - if (data.content.isNotEmpty()) { - page += 1 - } else { - isLast = true - } + detailResponse = data + _detail.value = detailResponse } else { _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다." } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkInfoFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkInfoFragment.kt new file mode 100644 index 00000000..a32c14c3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkInfoFragment.kt @@ -0,0 +1,171 @@ +package kr.co.vividnext.sodalive.chat.original.detail + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.net.toUri +import androidx.core.view.isGone +import androidx.core.view.isVisible +import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.databinding.FragmentOriginalWorkInfoBinding + +class OriginalWorkInfoFragment : BaseFragment( + FragmentOriginalWorkInfoBinding::inflate +) { + private var originalWorkDetailResponse: OriginalWorkDetailResponse? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (arguments != null) { + originalWorkDetailResponse = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requireArguments().getParcelable( + OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL, + OriginalWorkDetailResponse::class.java + ) + } else { + @Suppress("DEPRECATION") + requireArguments().getParcelable( + OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val data = originalWorkDetailResponse ?: return + + // 1. 작품 소개 + binding.tvDesc.text = data.description + + // 2-3. 원작 보러 가기 섹션 + val links = data.originalLinks + if (links.isEmpty()) { + binding.llOriginalLink.isGone = true + } else { + binding.llOriginalLink.isVisible = true + binding.llOriginalLinks.removeAllViews() + links.forEachIndexed { index, url -> + val tv = createLinkTextView(url, index) + binding.llOriginalLinks.addView(tv) + } + } + + // 4. 상세 정보 - 작가 + val writer = data.writer + if (writer.isNullOrBlank()) { + binding.tvLabelWriter.isGone = true + binding.tvWriter.isGone = true + } else { + binding.tvLabelWriter.isVisible = true + binding.tvWriter.isVisible = true + binding.tvWriter.text = writer + } + + // 4. 상세 정보 - 제작사 + val studio = data.studio + if (studio.isNullOrBlank()) { + binding.tvLabelStudio.isGone = true + binding.tvStudio.isGone = true + } else { + binding.tvLabelStudio.isVisible = true + binding.tvStudio.isVisible = true + binding.tvStudio.text = studio + } + + // 4. 상세 정보 - 원작 (원작명 + 링크) + val originalWork = data.originalWork + val originalLink = data.originalLink + if (originalWork.isNullOrBlank()) { + binding.tvLabelOriginal.isGone = true + binding.tvOriginalWork.isGone = true + } else { + binding.tvLabelOriginal.isVisible = true + binding.tvOriginalWork.isVisible = true + binding.tvOriginalWork.text = originalWork + if (!originalLink.isNullOrBlank()) { + binding.tvOriginalWork.isClickable = true + // 밑줄 표시로 링크 가능함을 시각적으로 안내 + binding.tvOriginalWork.paintFlags = + binding.tvOriginalWork.paintFlags or android.graphics.Paint.UNDERLINE_TEXT_FLAG + // Ripple 효과 추가로 터치 피드백 제공 + runCatching { + val outValue = android.util.TypedValue() + requireContext().theme.resolveAttribute( + android.R.attr.selectableItemBackground, + outValue, + true + ) + binding.tvOriginalWork.setBackgroundResource(outValue.resourceId) + } + // 접근성 설명 + binding.tvOriginalWork.contentDescription = "원작 $originalWork 링크 열기" + + binding.tvOriginalWork.setOnClickListener { + openUrl(originalLink) + } + } else { + binding.tvOriginalWork.isClickable = false + // 링크가 없을 경우 밑줄/리플 제거 + binding.tvOriginalWork.paintFlags = + binding.tvOriginalWork.paintFlags and android.graphics.Paint.UNDERLINE_TEXT_FLAG.inv() + binding.tvOriginalWork.setBackgroundResource(0) + binding.tvOriginalWork.contentDescription = originalWork + binding.tvOriginalWork.setOnClickListener(null) + } + } + } + + private fun createLinkTextView(url: String, index: Int): TextView { + val tv = TextView(requireContext()) + tv.text = extractDisplayText(url, index) + tv.setTextColor(requireContext().getColor(android.R.color.white)) + tv.textSize = 14f + tv.isClickable = true + tv.setOnClickListener { openUrl(url) } + + val lp = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + lp.rightMargin = (8 * resources.displayMetrics.density).toInt() + lp.topMargin = (4 * resources.displayMetrics.density).toInt() + tv.layoutParams = lp + + tv.setPadding( + (12 * resources.displayMetrics.density).toInt(), + (6 * resources.displayMetrics.density).toInt(), + (12 * resources.displayMetrics.density).toInt(), + (6 * resources.displayMetrics.density).toInt() + ) + // Chip 같은 느낌의 배경이 프로젝트에 없을 수 있어 기본 투명 배경 유지 + return tv + } + + private fun extractDisplayText(url: String, index: Int): String { + return try { + val uri = url.toUri() + val host = uri.host + if (!host.isNullOrBlank()) host else url + } catch (_: Exception) { + // 파싱 실패 시 간단한 레이블 제공 + "링크 ${index + 1}" + } + } + + private fun openUrl(url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(intent) + } catch (_: Exception) { + // 안전상 silently ignore 또는 토스트 노출이 가능 하다면 추가 + } + } +} diff --git a/app/src/main/res/layout/activity_original_work_detail.xml b/app/src/main/res/layout/activity_original_work_detail.xml index 8802a5fe..61c82921 100644 --- a/app/src/main/res/layout/activity_original_work_detail.xml +++ b/app/src/main/res/layout/activity_original_work_detail.xml @@ -47,14 +47,137 @@ - + app:layout_constraintTop_toBottomOf="@+id/rl_toolbar"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_original_work_character.xml b/app/src/main/res/layout/fragment_original_work_character.xml new file mode 100644 index 00000000..80bd1b39 --- /dev/null +++ b/app/src/main/res/layout/fragment_original_work_character.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_original_work_info.xml b/app/src/main/res/layout/fragment_original_work_info.xml new file mode 100644 index 00000000..28a573b3 --- /dev/null +++ b/app/src/main/res/layout/fragment_original_work_info.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_original_detail_header.xml b/app/src/main/res/layout/item_original_detail_header.xml deleted file mode 100644 index 0fa6e840..00000000 --- a/app/src/main/res/layout/item_original_detail_header.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -