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

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

View File

@@ -20,12 +20,4 @@ interface OriginalWorkApi {
@Header("Authorization") authHeader: String,
@Path("id") id: Long
): 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
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<String>,
@SerializedName("tags") val tags: List<String>,
@SerializedName("characters") val characters: List<Character>
)
) : Parcelable

View File

@@ -20,13 +20,4 @@ class OriginalWorkRepository(
): Single<ApiResponse<OriginalWorkDetailResponse>> {
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
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<ActivityOriginalWorkDetailBindin
companion object {
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 lateinit var adapter: OriginalWorkDetailAdapter
private var originalId: Long = -1
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
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()
if (originalId > 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<ActivityOriginalWorkDetailBindin
// Toolbar back
binding.ivBack.setOnClickListener { finish() }
setupTabs()
}
private fun setupRecycler() {
adapter = OriginalWorkDetailAdapter(
onClickOpenLink = { url ->
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")
}
}
}
}

View File

@@ -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<Character> = emptyList(),
private val onClickCharacter: (Long) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 작품소개 확장 상태 (헤더 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<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) {
) : RecyclerView.Adapter<OriginalWorkDetailAdapter.ItemVH>() {
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<Character>) {
items = chars
notifyDataSetChanged()
}
}

View File

@@ -23,12 +23,7 @@ class OriginalWorkDetailViewModel(
private val _detail = MutableLiveData<OriginalWorkDetailResponse?>(null)
val detail: LiveData<OriginalWorkDetailResponse?> get() = _detail
private val _characters = MutableLiveData<List<Character>>(emptyList())
val characters: LiveData<List<Character>> 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 ?: "알 수 없는 오류가 발생했습니다."
}

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>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_detail"
<androidx.core.widget.NestedScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/rl_toolbar"
tools:listitem="@layout/item_original_detail_character" />
app:layout_constraintTop_toBottomOf="@+id/rl_toolbar">
<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>

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>