feat(original): UI 변경

- 캐릭터 / 작품 정보 탭 추가
- 작품 정보 탭 구성
  - 작품 소개
  - 원작 보러 가기
  - 상세 정보
    - 작가
    - 제작사
    - 원작
This commit is contained in:
2025-09-19 04:15:30 +09:00
parent 44e209d7b1
commit 0319981650
14 changed files with 725 additions and 355 deletions

View File

@@ -35,7 +35,7 @@ android {
applicationId "kr.co.vividnext.sodalive" applicationId "kr.co.vividnext.sodalive"
minSdk 23 minSdk 23
targetSdk 34 targetSdk 34
versionCode 188 versionCode 189
versionName "1.42.1" versionName "1.42.1"
} }

View File

@@ -1,12 +1,15 @@
package kr.co.vividnext.sodalive.chat.character package kr.co.vividnext.sodalive.chat.character
import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
@Keep @Keep
data class Character( data class Character(
@SerializedName("characterId") val characterId: Long, @SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String, @SerializedName("name") val name: String,
@SerializedName("description") val description: String, @SerializedName("description") val description: String,
@SerializedName("imageUrl") val imageUrl: String @SerializedName("imageUrl") val imageUrl: String
) ) : Parcelable

View File

@@ -20,12 +20,4 @@ interface OriginalWorkApi {
@Header("Authorization") authHeader: String, @Header("Authorization") authHeader: String,
@Path("id") id: Long @Path("id") id: Long
): Single<ApiResponse<OriginalWorkDetailResponse>> ): Single<ApiResponse<OriginalWorkDetailResponse>>
@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<ApiResponse<OriginalWorkCharactersPageResponse>>
} }

View File

@@ -1,9 +1,12 @@
package kr.co.vividnext.sodalive.chat.original package kr.co.vividnext.sodalive.chat.original
import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import kr.co.vividnext.sodalive.chat.character.Character import kr.co.vividnext.sodalive.chat.character.Character
@Parcelize
@Keep @Keep
data class OriginalWorkDetailResponse( data class OriginalWorkDetailResponse(
@SerializedName("imageUrl") val imageUrl: String?, @SerializedName("imageUrl") val imageUrl: String?,
@@ -12,6 +15,11 @@ data class OriginalWorkDetailResponse(
@SerializedName("category") val category: String, @SerializedName("category") val category: String,
@SerializedName("isAdult") val isAdult: Boolean, @SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("description") val description: String, @SerializedName("description") val description: String,
@SerializedName("originalWork") val originalWork: String?,
@SerializedName("originalLink") val originalLink: String?, @SerializedName("originalLink") val originalLink: String?,
@SerializedName("writer") val writer: String?,
@SerializedName("studio") val studio: String?,
@SerializedName("originalLinks") val originalLinks: List<String>,
@SerializedName("tags") val tags: List<String>,
@SerializedName("characters") val characters: List<Character> @SerializedName("characters") val characters: List<Character>
) ) : Parcelable

View File

@@ -20,13 +20,4 @@ class OriginalWorkRepository(
): Single<ApiResponse<OriginalWorkDetailResponse>> { ): Single<ApiResponse<OriginalWorkDetailResponse>> {
return api.getOriginalWorkDetail(token, id) return api.getOriginalWorkDetail(token, id)
} }
fun getOriginalCharacters(
token: String,
id: Long,
page: Int,
size: Int
): Single<ApiResponse<OriginalWorkCharactersPageResponse>> {
return api.getOriginalWorkCharacters(token, id, page, size)
}
} }

View File

@@ -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>(
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
}
}

View File

@@ -1,18 +1,16 @@
package kr.co.vividnext.sodalive.chat.original.detail package kr.co.vividnext.sodalive.chat.original.detail
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import android.view.View
import androidx.recyclerview.widget.GridLayoutManager import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import coil.size.Scale import coil.size.Scale
import coil.transform.BlurTransformation 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.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity import kr.co.vividnext.sodalive.common.LoadingDialog
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.databinding.ActivityOriginalWorkDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityOriginalWorkDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -23,25 +21,29 @@ class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBindin
companion object { companion object {
const val EXTRA_ORIGINAL_ID = "extra_original_id" const val EXTRA_ORIGINAL_ID = "extra_original_id"
const val EXTRA_ORIGINAL_WORK_DETAIL = "extra_original_work_detail"
} }
private val viewModel: OriginalWorkDetailViewModel by inject() private val viewModel: OriginalWorkDetailViewModel by inject()
private lateinit var adapter: OriginalWorkDetailAdapter private lateinit var loadingDialog: LoadingDialog
private var originalId: Long = -1
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
originalId = intent.getLongExtra(EXTRA_ORIGINAL_ID, -1)
setupRecycler() val originalId = intent.getLongExtra(EXTRA_ORIGINAL_ID, -1)
if (originalId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
finish()
}
bind() bind()
if (originalId > 0) viewModel.loadDetail(originalId) viewModel.loadDetail(originalId)
} }
override fun setupView() { override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
// 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:432) // 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:432)
binding.ivBg.post { binding.ivBg.post {
val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
@@ -53,48 +55,66 @@ class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBindin
// Toolbar back // Toolbar back
binding.ivBack.setOnClickListener { finish() } binding.ivBack.setOnClickListener { finish() }
setupTabs()
} }
private fun setupRecycler() { private fun setupTabs() {
adapter = OriginalWorkDetailAdapter( val tabs = binding.tabs
onClickOpenLink = { url -> tabs.addTab(tabs.newTab().setText("캐릭터").setTag("character"))
startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) tabs.addTab(tabs.newTab().setText("작품정보").setTag("info"))
},
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
binding.rvDetail.addOnScrollListener(object : RecyclerView.OnScrollListener() { tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onTabSelected(tab: TabLayout.Tab) {
super.onScrolled(recyclerView, dx, dy) val tag = tab.tag as String
if (!recyclerView.canScrollVertically(1)) { changeFragment(tag)
if (originalId > 0) viewModel.loadMoreCharacters(originalId)
} }
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() { private fun bind() {
viewModel.toast.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.detail.observe(this) { data -> viewModel.detail.observe(this) { data ->
adapter.setHeader(data) if (data != null) {
// 배경 이미지 Blur 처리 및 채우기 // 배경 이미지 Blur 처리 및 채우기
val imageUrl = data?.imageUrl val imageUrl = data.imageUrl
if (!imageUrl.isNullOrBlank()) { if (!imageUrl.isNullOrBlank()) {
binding.ivBg.load(imageUrl) { binding.ivBg.load(imageUrl) {
transformations( transformations(
@@ -109,9 +129,32 @@ class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBindin
} else { } else {
binding.ivBg.setImageResource(R.drawable.bg_placeholder) 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")
} }
viewModel.characters.observe(this) { list ->
adapter.setItems(list)
} }
} }
} }

View File

@@ -1,128 +1,24 @@
package kr.co.vividnext.sodalive.chat.original.detail package kr.co.vividnext.sodalive.chat.original.detail
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.text.TextUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.character.Character 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.ItemOriginalDetailCharacterBinding
import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailHeaderBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
class OriginalWorkDetailAdapter( class OriginalWorkDetailAdapter(
private val onClickOpenLink: (String) -> Unit, private var items: List<Character> = emptyList(),
private val onClickCharacter: (Long) -> Unit private val onClickCharacter: (Long) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<OriginalWorkDetailAdapter.ItemVH>() {
inner class ItemVH(
// 작품소개 확장 상태 (헤더 1개이므로 어댑터 레벨에서 유지) private val binding: ItemOriginalDetailCharacterBinding
private var isDescriptionExpanded: Boolean = false ) : RecyclerView.ViewHolder(binding.root) {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
}
private var header: OriginalWorkDetailResponse? = null
private val items = mutableListOf<Character>()
fun setHeader(data: OriginalWorkDetailResponse?) {
header = data
notifyItemChanged(0)
}
@SuppressLint("NotifyDataSetChanged")
fun setItems(chars: List<Character>) {
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) {
fun bind(item: Character) { fun bind(item: Character) {
binding.tvCharacterName.text = item.name binding.tvCharacterName.text = item.name
binding.tvCharacterDescription.text = item.description binding.tvCharacterDescription.text = item.description
@@ -134,4 +30,25 @@ class OriginalWorkDetailAdapter(
binding.root.setOnClickListener { onClickCharacter(item.characterId) } 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<Character>) {
items = chars
notifyDataSetChanged()
}
} }

View File

@@ -23,12 +23,7 @@ class OriginalWorkDetailViewModel(
private val _detail = MutableLiveData<OriginalWorkDetailResponse?>(null) private val _detail = MutableLiveData<OriginalWorkDetailResponse?>(null)
val detail: LiveData<OriginalWorkDetailResponse?> get() = _detail val detail: LiveData<OriginalWorkDetailResponse?> get() = _detail
private val _characters = MutableLiveData<List<Character>>(emptyList()) lateinit var detailResponse: OriginalWorkDetailResponse
val characters: LiveData<List<Character>> get() = _characters
private val size = 20
private var page = 1 // 초기 로딩 이후부터 사용하므로 1부터 시작
private var isLast = false
fun loadDetail(id: Long) { fun loadDetail(id: Long) {
if (_isLoading.value == true) return if (_isLoading.value == true) return
@@ -43,46 +38,8 @@ class OriginalWorkDetailViewModel(
.subscribe({ response -> .subscribe({ response ->
val data = response.data val data = response.data
if (response.success && data != null) { if (response.success && data != null) {
_detail.value = data detailResponse = data
// 상세 응답 내 캐릭터를 초기 세팅 _detail.value = detailResponse
_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
}
} else { } else {
_toast.value = response.message ?: "알 수 없는 오류가 발생했습니다." _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다."
} }

View File

@@ -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>(
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 또는 토스트 노출이 가능 하다면 추가
}
}
}

View File

@@ -47,14 +47,137 @@
</RelativeLayout> </RelativeLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.core.widget.NestedScrollView
android:id="@+id/rv_detail"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rl_toolbar" app:layout_constraintTop_toBottomOf="@+id/rl_toolbar">
tools:listitem="@layout/item_original_detail_character" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="24dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp">
<!-- Cover small card -->
<ImageView
android:id="@+id/iv_cover"
android:layout_width="168dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="306:432"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_logo_service_center" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:fontFamily="@font/pretendard_bold"
android:gravity="center"
android:textColor="@color/white"
android:textSize="26sp"
tools:text="작품 제목" />
<LinearLayout
android:id="@+id/ll_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_content_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_round_corner_4_263238_ffffff"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:textColor="#B0BEC5"
android:textSize="14sp"
tools:text="웹소설" />
<TextView
android:id="@+id/tv_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_round_corner_4_263238_3bb9f1"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:textColor="#3bb9f1"
android:textSize="14sp"
tools:text="로맨스" />
<TextView
android:id="@+id/tv_adult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_round_corner_4_263238_ff5c49"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:text="19+"
android:textColor="#FF5C49"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_tags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:ellipsize="end"
android:fontFamily="@font/pretendard_regular"
android:gravity="center"
android:textColor="@color/color_3bb9f1"
android:textSize="14sp"
tools:text="#태그1 #태그2" />
</LinearLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/black"
app:tabIndicatorColor="@color/color_3bb9f1"
app:tabIndicatorFullWidth="true"
app:tabIndicatorHeight="4dp"
app:tabSelectedTextColor="@color/color_3bb9f1"
app:tabTextAppearance="@style/tabText"
app:tabTextColor="@color/color_777777" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/color_88909090" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,15 @@
<?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="wrap_content"
android:background="@color/black">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_character"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_original_detail_character" />
</LinearLayout>

View File

@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="wrap_content"
android:background="@color/black"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_round_corner_16_263238"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/pretendard_bold"
android:text="작품 소개"
android:textColor="@color/white"
android:textSize="16sp" />
<io.github.glailton.expandabletextview.ExpandableTextView
android:id="@+id/tv_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/pretendard_regular"
android:textColor="#B0BEC5"
android:textSize="14sp"
app:animDuration="500"
app:collapsedLines="3"
app:ellipsizeTextColor="@color/white"
app:expandType="layout"
app:isExpanded="false"
app:readLessText="간략히"
app:readMoreText="전체보기"
app:textMode="line"
tools:text="특별한 꽃을 길러낼 수 있는 능력을 가진 리엘라.\n그녀는 호손 공작의 상속자가 되고 말아버리는데...\n생각하지도 못했던 상속에 당황한 리엘라의 앞에 왕의 동생이자 보석술사인 하운 대공이 나타난다.\n바로 그녀의 특별한 ‘능력’ 때문에!\n\n꽃집 소녀 리엘라의 우당탕탕 공작 상속기!\n두 명의 상속인" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_original_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_round_corner_16_263238"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/pretendard_bold"
android:text="원작 보러 가기"
android:textColor="#B0BEC5"
android:textSize="16sp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/ll_original_links"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</HorizontalScrollView>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_round_corner_16_263238"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/pretendard_bold"
android:text="상세 정보"
android:textColor="@color/white"
android:textSize="16sp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_detail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_labels_end"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="tv_label_writer,tv_label_studio,tv_label_original" />
<!-- 작가 라벨/내용 -->
<TextView
android:id="@+id/tv_label_writer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pretendard_regular"
android:text="작가"
android:textColor="#B0BEC5"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_writer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:fontFamily="@font/pretendard_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/tv_label_writer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/barrier_labels_end"
app:layout_constraintTop_toTopOf="@id/tv_label_writer"
tools:text="writer" />
<!-- 제작사 라벨/내용 -->
<TextView
android:id="@+id/tv_label_studio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/pretendard_regular"
android:text="제작사"
android:textColor="#B0BEC5"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_writer" />
<TextView
android:id="@+id/tv_studio"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:fontFamily="@font/pretendard_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/tv_label_studio"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/barrier_labels_end"
app:layout_constraintTop_toTopOf="@id/tv_label_studio"
tools:text="studio" />
<!-- 원작 라벨/내용 -->
<TextView
android:id="@+id/tv_label_original"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/pretendard_regular"
android:text="원작"
android:textColor="#B0BEC5"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_studio" />
<TextView
android:id="@+id/tv_original_work"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:fontFamily="@font/pretendard_regular"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/tv_label_original"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/barrier_labels_end"
app:layout_constraintTop_toTopOf="@id/tv_label_original"
tools:text="original work" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="wrap_content"
android:layout_marginBottom="24dp"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="24dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp">
<!-- Cover small card -->
<ImageView
android:id="@+id/iv_cover"
android:layout_width="168dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="306:432"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_logo_service_center" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:fontFamily="@font/pretendard_bold"
android:gravity="center"
android:textColor="@color/white"
android:textSize="26sp"
tools:text="작품 제목" />
<LinearLayout
android:id="@+id/ll_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_content_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_round_corner_4_263238_ffffff"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:textColor="#B0BEC5"
android:textSize="14sp"
tools:text="웹소설" />
<TextView
android:id="@+id/tv_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_round_corner_4_263238_3bb9f1"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:textColor="#3bb9f1"
android:textSize="14sp"
tools:text="로맨스" />
<TextView
android:id="@+id/tv_adult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/bg_round_corner_4_263238_ff5c49"
android:fontFamily="@font/pretendard_regular"
android:paddingHorizontal="7dp"
android:paddingVertical="3dp"
android:text="19+"
android:textColor="#FF5C49"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:ellipsize="end"
android:fontFamily="@font/pretendard_regular"
android:maxLines="2"
android:textColor="#CFD8DC"
android:textSize="14sp"
tools:text="작품 소개 텍스트가 표시됩니다." />
<TextView
android:id="@+id/tv_open_original"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/bg_round_corner_8_transparent_3bb9f1"
android:fontFamily="@font/pretendard_bold"
android:gravity="center"
android:paddingVertical="15dp"
android:text="원작 보러가기"
android:textAllCaps="false"
android:textColor="@color/color_3bb9f1"
android:textSize="16sp" />
</LinearLayout>