feat(original): UI 변경
- 캐릭터 / 작품 정보 탭 추가
- 작품 정보 탭 구성
  - 작품 소개
  - 원작 보러 가기
  - 상세 정보
    - 작가
    - 제작사
    - 원작
			
			
This commit is contained in:
		@@ -35,7 +35,7 @@ android {
 | 
			
		||||
        applicationId "kr.co.vividnext.sodalive"
 | 
			
		||||
        minSdk 23
 | 
			
		||||
        targetSdk 34
 | 
			
		||||
        versionCode 188
 | 
			
		||||
        versionCode 189
 | 
			
		||||
        versionName "1.42.1"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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>>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,48 +55,66 @@ 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.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 ->
 | 
			
		||||
            adapter.setHeader(data)
 | 
			
		||||
            if (data != null) {
 | 
			
		||||
                // 배경 이미지 Blur 처리 및 채우기
 | 
			
		||||
            val imageUrl = data?.imageUrl
 | 
			
		||||
                val imageUrl = data.imageUrl
 | 
			
		||||
                if (!imageUrl.isNullOrBlank()) {
 | 
			
		||||
                    binding.ivBg.load(imageUrl) {
 | 
			
		||||
                        transformations(
 | 
			
		||||
@@ -109,9 +129,32 @@ class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBindin
 | 
			
		||||
                } 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")
 | 
			
		||||
            }
 | 
			
		||||
        viewModel.characters.observe(this) { list ->
 | 
			
		||||
            adapter.setItems(list)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 ?: "알 수 없는 오류가 발생했습니다."
 | 
			
		||||
                    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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 또는 토스트 노출이 가능 하다면 추가
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								app/src/main/res/layout/fragment_original_work_character.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/src/main/res/layout/fragment_original_work_character.xml
									
									
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										186
									
								
								app/src/main/res/layout/fragment_original_work_info.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								app/src/main/res/layout/fragment_original_work_info.xml
									
									
									
									
									
										Normal 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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
		Reference in New Issue
	
	Block a user