feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현
This commit is contained in:
		@@ -192,6 +192,7 @@
 | 
			
		||||
            </intent-filter>
 | 
			
		||||
        </activity>
 | 
			
		||||
        <activity android:name=".chat.character.newcharacters.NewCharactersAllActivity" />
 | 
			
		||||
        <activity android:name=".chat.original.detail.OriginalWorkDetailActivity" />
 | 
			
		||||
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +1,48 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.original
 | 
			
		||||
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import android.view.Gravity
 | 
			
		||||
import android.view.View
 | 
			
		||||
import android.widget.Toast
 | 
			
		||||
import androidx.annotation.OptIn
 | 
			
		||||
import androidx.media3.common.util.UnstableApi
 | 
			
		||||
import androidx.recyclerview.widget.GridLayoutManager
 | 
			
		||||
import androidx.recyclerview.widget.LinearLayoutManager
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import com.google.gson.Gson
 | 
			
		||||
import kr.co.vividnext.sodalive.base.BaseFragment
 | 
			
		||||
import kr.co.vividnext.sodalive.base.SodaDialog
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailActivity
 | 
			
		||||
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
 | 
			
		||||
import kr.co.vividnext.sodalive.common.LoadingDialog
 | 
			
		||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
 | 
			
		||||
import kr.co.vividnext.sodalive.databinding.FragmentOriginalTabBinding
 | 
			
		||||
import kr.co.vividnext.sodalive.extensions.dpToPx
 | 
			
		||||
import kr.co.vividnext.sodalive.main.MainActivity
 | 
			
		||||
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
 | 
			
		||||
import kr.co.vividnext.sodalive.mypage.auth.Auth
 | 
			
		||||
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
 | 
			
		||||
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
 | 
			
		||||
import kr.co.vividnext.sodalive.splash.SplashActivity
 | 
			
		||||
import org.koin.android.ext.android.inject
 | 
			
		||||
 | 
			
		||||
@OptIn(UnstableApi::class)
 | 
			
		||||
class OriginalTabFragment :
 | 
			
		||||
    BaseFragment<FragmentOriginalTabBinding>(FragmentOriginalTabBinding::inflate) {
 | 
			
		||||
 | 
			
		||||
    private val viewModel: OriginalWorkViewModel by inject()
 | 
			
		||||
    private val myPageViewModel: MyPageViewModel by inject()
 | 
			
		||||
 | 
			
		||||
    private lateinit var adapter: OriginalWorkListAdapter
 | 
			
		||||
 | 
			
		||||
    private lateinit var loadingDialog: LoadingDialog
 | 
			
		||||
 | 
			
		||||
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
 | 
			
		||||
        super.onViewCreated(view, savedInstanceState)
 | 
			
		||||
 | 
			
		||||
        loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
 | 
			
		||||
 | 
			
		||||
        setupRecycler()
 | 
			
		||||
        bind()
 | 
			
		||||
        viewModel.loadMore()
 | 
			
		||||
@@ -28,7 +51,18 @@ class OriginalTabFragment :
 | 
			
		||||
    private fun setupRecycler() {
 | 
			
		||||
        val spanCount = 3
 | 
			
		||||
        val spacingPx = 16f.dpToPx().toInt()
 | 
			
		||||
        adapter = OriginalWorkListAdapter { /* TODO: 상세 페이지 이동 정의 시 연결 */ }
 | 
			
		||||
        adapter = OriginalWorkListAdapter { id ->
 | 
			
		||||
            ensureLoginAndAuth {
 | 
			
		||||
                startActivity(
 | 
			
		||||
                    Intent(
 | 
			
		||||
                        requireContext(),
 | 
			
		||||
                        OriginalWorkDetailActivity::class.java
 | 
			
		||||
                    ).apply {
 | 
			
		||||
                        this.putExtra(OriginalWorkDetailActivity.EXTRA_ORIGINAL_ID, id)
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount)
 | 
			
		||||
        binding.rvOriginal.addItemDecoration(
 | 
			
		||||
            GridSpacingItemDecoration(
 | 
			
		||||
@@ -56,6 +90,68 @@ class OriginalTabFragment :
 | 
			
		||||
            // 누적 리스트를 어댑터에 추가
 | 
			
		||||
            adapter.addItems(list.drop(adapter.itemCount))
 | 
			
		||||
        }
 | 
			
		||||
        // 필요 시 로딩/토스트 처리 추가
 | 
			
		||||
 | 
			
		||||
        viewModel.isLoading.observe(viewLifecycleOwner) {
 | 
			
		||||
            if (it) {
 | 
			
		||||
                loadingDialog.show(screenWidth)
 | 
			
		||||
            } else {
 | 
			
		||||
                loadingDialog.dismiss()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        viewModel.toast.observe(viewLifecycleOwner) {
 | 
			
		||||
            it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun ensureLoginAndAuth(onAuthed: () -> Unit) {
 | 
			
		||||
        if (SharedPreferenceManager.token.isBlank()) {
 | 
			
		||||
            (requireActivity() as MainActivity).showLoginActivity()
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!SharedPreferenceManager.isAuth) {
 | 
			
		||||
            SodaDialog(
 | 
			
		||||
                activity = requireActivity(),
 | 
			
		||||
                layoutInflater = layoutInflater,
 | 
			
		||||
                title = "본인인증",
 | 
			
		||||
                desc = "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" +
 | 
			
		||||
                    "캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.",
 | 
			
		||||
                confirmButtonTitle = "본인인증 하러가기",
 | 
			
		||||
                confirmButtonClick = { startAuthFlow() },
 | 
			
		||||
                cancelButtonTitle = "취소",
 | 
			
		||||
                cancelButtonClick = {},
 | 
			
		||||
                descGravity = Gravity.CENTER
 | 
			
		||||
            ).show(screenWidth)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        onAuthed()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun startAuthFlow() {
 | 
			
		||||
        Auth.auth(requireActivity(), requireContext()) { json ->
 | 
			
		||||
            val bootpayResponse = Gson().fromJson(
 | 
			
		||||
                json,
 | 
			
		||||
                BootpayResponse::class.java
 | 
			
		||||
            )
 | 
			
		||||
            val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
 | 
			
		||||
            requireActivity().runOnUiThread {
 | 
			
		||||
                myPageViewModel.authVerify(request) {
 | 
			
		||||
                    startActivity(
 | 
			
		||||
                        Intent(
 | 
			
		||||
                            requireContext(),
 | 
			
		||||
                            SplashActivity::class.java
 | 
			
		||||
                        ).apply {
 | 
			
		||||
                            addFlags(
 | 
			
		||||
                                Intent.FLAG_ACTIVITY_CLEAR_TASK or
 | 
			
		||||
                                    Intent.FLAG_ACTIVITY_NEW_TASK
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                    )
 | 
			
		||||
                    requireActivity().finish()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Single
 | 
			
		||||
import kr.co.vividnext.sodalive.common.ApiResponse
 | 
			
		||||
import retrofit2.http.GET
 | 
			
		||||
import retrofit2.http.Header
 | 
			
		||||
import retrofit2.http.Path
 | 
			
		||||
import retrofit2.http.Query
 | 
			
		||||
 | 
			
		||||
interface OriginalWorkApi {
 | 
			
		||||
@@ -13,4 +14,18 @@ interface OriginalWorkApi {
 | 
			
		||||
        @Query("page") page: Int,
 | 
			
		||||
        @Query("size") size: Int
 | 
			
		||||
    ): Single<ApiResponse<OriginalWorkListResponse>>
 | 
			
		||||
 | 
			
		||||
    @GET("/api/chat/original/{id}")
 | 
			
		||||
    fun getOriginalWorkDetail(
 | 
			
		||||
        @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>>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,11 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.original
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.Keep
 | 
			
		||||
import com.google.gson.annotations.SerializedName
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.character.Character
 | 
			
		||||
 | 
			
		||||
@Keep
 | 
			
		||||
data class OriginalWorkCharactersPageResponse(
 | 
			
		||||
    @SerializedName("totalCount") val totalCount: Long,
 | 
			
		||||
    @SerializedName("content") val content: List<Character>
 | 
			
		||||
)
 | 
			
		||||
@@ -0,0 +1,17 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.original
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.Keep
 | 
			
		||||
import com.google.gson.annotations.SerializedName
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.character.Character
 | 
			
		||||
 | 
			
		||||
@Keep
 | 
			
		||||
data class OriginalWorkDetailResponse(
 | 
			
		||||
    @SerializedName("imageUrl") val imageUrl: String?,
 | 
			
		||||
    @SerializedName("title") val title: String,
 | 
			
		||||
    @SerializedName("contentType") val contentType: String,
 | 
			
		||||
    @SerializedName("category") val category: String,
 | 
			
		||||
    @SerializedName("isAdult") val isAdult: Boolean,
 | 
			
		||||
    @SerializedName("description") val description: String,
 | 
			
		||||
    @SerializedName("originalLink") val originalLink: String?,
 | 
			
		||||
    @SerializedName("characters") val characters: List<Character>
 | 
			
		||||
)
 | 
			
		||||
@@ -6,7 +6,27 @@ import kr.co.vividnext.sodalive.common.ApiResponse
 | 
			
		||||
class OriginalWorkRepository(
 | 
			
		||||
    private val api: OriginalWorkApi
 | 
			
		||||
) {
 | 
			
		||||
    fun getOriginalWorks(token: String, page: Int, size: Int): Single<ApiResponse<OriginalWorkListResponse>> {
 | 
			
		||||
    fun getOriginalWorks(
 | 
			
		||||
        token: String,
 | 
			
		||||
        page: Int,
 | 
			
		||||
        size: Int
 | 
			
		||||
    ): Single<ApiResponse<OriginalWorkListResponse>> {
 | 
			
		||||
        return api.getOriginalWorkList(token, page, size)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun getOriginalDetail(
 | 
			
		||||
        token: String,
 | 
			
		||||
        id: Long
 | 
			
		||||
    ): 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,117 @@
 | 
			
		||||
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 coil.load
 | 
			
		||||
import coil.size.Scale
 | 
			
		||||
import coil.transform.BlurTransformation
 | 
			
		||||
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.databinding.ActivityOriginalWorkDetailBinding
 | 
			
		||||
import kr.co.vividnext.sodalive.extensions.dpToPx
 | 
			
		||||
import org.koin.android.ext.android.inject
 | 
			
		||||
 | 
			
		||||
class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBinding>(
 | 
			
		||||
    ActivityOriginalWorkDetailBinding::inflate
 | 
			
		||||
) {
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        const val EXTRA_ORIGINAL_ID = "extra_original_id"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private val viewModel: OriginalWorkDetailViewModel by inject()
 | 
			
		||||
 | 
			
		||||
    private lateinit var adapter: OriginalWorkDetailAdapter
 | 
			
		||||
 | 
			
		||||
    private var originalId: Long = -1
 | 
			
		||||
 | 
			
		||||
    override fun onCreate(savedInstanceState: Bundle?) {
 | 
			
		||||
        super.onCreate(savedInstanceState)
 | 
			
		||||
        originalId = intent.getLongExtra(EXTRA_ORIGINAL_ID, -1)
 | 
			
		||||
 | 
			
		||||
        setupRecycler()
 | 
			
		||||
        bind()
 | 
			
		||||
 | 
			
		||||
        if (originalId > 0) viewModel.loadDetail(originalId)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun setupView() {
 | 
			
		||||
        // 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:432)
 | 
			
		||||
        binding.ivBg.post {
 | 
			
		||||
            val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
 | 
			
		||||
            val height = width * 432 / 306
 | 
			
		||||
            val lp = binding.ivBg.layoutParams
 | 
			
		||||
            lp.height = height
 | 
			
		||||
            binding.ivBg.layoutParams = lp
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Toolbar back
 | 
			
		||||
        binding.ivBack.setOnClickListener { finish() }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                binding.ivBg.setImageResource(R.drawable.bg_placeholder)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        viewModel.characters.observe(this) { list ->
 | 
			
		||||
            adapter.setItems(list)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,137 @@
 | 
			
		||||
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 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 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) {
 | 
			
		||||
        fun bind(item: Character) {
 | 
			
		||||
            binding.tvCharacterName.text = item.name
 | 
			
		||||
            binding.tvCharacterDescription.text = item.description
 | 
			
		||||
            binding.ivCharacter.load(item.imageUrl) {
 | 
			
		||||
                crossfade(true)
 | 
			
		||||
                placeholder(R.drawable.ic_logo_service_center)
 | 
			
		||||
                transformations(RoundedCornersTransformation(16f.dpToPx()))
 | 
			
		||||
            }
 | 
			
		||||
            binding.root.setOnClickListener { onClickCharacter(item.characterId) }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,96 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.original.detail
 | 
			
		||||
 | 
			
		||||
import androidx.lifecycle.LiveData
 | 
			
		||||
import androidx.lifecycle.MutableLiveData
 | 
			
		||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
 | 
			
		||||
import io.reactivex.rxjava3.schedulers.Schedulers
 | 
			
		||||
import kr.co.vividnext.sodalive.base.BaseViewModel
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.character.Character
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
 | 
			
		||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
 | 
			
		||||
 | 
			
		||||
class OriginalWorkDetailViewModel(
 | 
			
		||||
    private val repository: OriginalWorkRepository
 | 
			
		||||
) : BaseViewModel() {
 | 
			
		||||
 | 
			
		||||
    private val _isLoading = MutableLiveData(false)
 | 
			
		||||
    val isLoading: LiveData<Boolean> get() = _isLoading
 | 
			
		||||
 | 
			
		||||
    private val _toast = MutableLiveData<String?>(null)
 | 
			
		||||
    val toast: LiveData<String?> get() = _toast
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    fun loadDetail(id: Long) {
 | 
			
		||||
        if (_isLoading.value == true) return
 | 
			
		||||
        _isLoading.value = true
 | 
			
		||||
        compositeDisposable.add(
 | 
			
		||||
            repository.getOriginalDetail(
 | 
			
		||||
                token = "Bearer ${SharedPreferenceManager.token}",
 | 
			
		||||
                id = id
 | 
			
		||||
            )
 | 
			
		||||
                .subscribeOn(Schedulers.io())
 | 
			
		||||
                .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
                .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
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다."
 | 
			
		||||
                    }
 | 
			
		||||
                    _isLoading.value = false
 | 
			
		||||
                }, { e ->
 | 
			
		||||
                    _isLoading.value = false
 | 
			
		||||
                    _toast.value = e.message ?: "알 수 없는 오류가 발생했습니다."
 | 
			
		||||
                })
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -5,11 +5,14 @@ import android.view.View
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Grid 간격 데코레이션. 헤더가 있는 경우 headerCount로 보정하여 첫 행 판단 및 컬럼 계산을 정확히 수행한다.
 | 
			
		||||
 */
 | 
			
		||||
class GridSpacingItemDecoration(
 | 
			
		||||
    private val spanCount: Int,
 | 
			
		||||
    private val spacing: Int,
 | 
			
		||||
    private val includeEdge: Boolean
 | 
			
		||||
    private val includeEdge: Boolean,
 | 
			
		||||
    private val headerCount: Int = 0
 | 
			
		||||
) : ItemDecoration() {
 | 
			
		||||
    override fun getItemOffsets(
 | 
			
		||||
        outRect: Rect,
 | 
			
		||||
@@ -17,19 +20,26 @@ class GridSpacingItemDecoration(
 | 
			
		||||
        parent: RecyclerView,
 | 
			
		||||
        state: RecyclerView.State
 | 
			
		||||
    ) {
 | 
			
		||||
        val position = parent.getChildAdapterPosition(view) // Item position
 | 
			
		||||
        val column = position % spanCount // Current column
 | 
			
		||||
        val position = parent.getChildAdapterPosition(view)
 | 
			
		||||
        // 헤더 범위는 간격을 적용하지 않음
 | 
			
		||||
        val adjustedPosition = position - headerCount
 | 
			
		||||
        if (adjustedPosition < 0) {
 | 
			
		||||
            outRect.set(0, 0, 0, 0)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val column = adjustedPosition % spanCount
 | 
			
		||||
        if (includeEdge) {
 | 
			
		||||
            outRect.left = spacing - column * spacing / spanCount
 | 
			
		||||
            outRect.right = (column + 1) * spacing / spanCount
 | 
			
		||||
            if (position < spanCount) { // Top edge
 | 
			
		||||
            if (adjustedPosition < spanCount) { // Top edge (헤더 제외 첫 행)
 | 
			
		||||
                outRect.top = spacing
 | 
			
		||||
            }
 | 
			
		||||
            outRect.bottom = spacing // Item bottom
 | 
			
		||||
        } else {
 | 
			
		||||
            outRect.left = column * spacing / spanCount
 | 
			
		||||
            outRect.right = spacing - (column + 1) * spacing / spanCount
 | 
			
		||||
            if (position >= spanCount) {
 | 
			
		||||
            if (adjustedPosition >= spanCount) {
 | 
			
		||||
                outRect.top = spacing // Item top
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -372,6 +372,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
 | 
			
		||||
        viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) }
 | 
			
		||||
        viewModel { NewCharactersAllViewModel(get()) }
 | 
			
		||||
        viewModel { OriginalWorkViewModel(get()) }
 | 
			
		||||
        viewModel { kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailViewModel(get()) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private val repositoryModule = module {
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
 | 
			
		||||
    android:shape="rectangle">
 | 
			
		||||
    <corners android:radius="4dp" />
 | 
			
		||||
    <solid android:color="#263238" />
 | 
			
		||||
    <stroke
 | 
			
		||||
        android:width="1dp"
 | 
			
		||||
        android:color="@color/color_3bb9f1" />
 | 
			
		||||
</shape>
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
 | 
			
		||||
    android:shape="rectangle">
 | 
			
		||||
    <corners android:radius="4dp" />
 | 
			
		||||
    <solid android:color="#263238" />
 | 
			
		||||
    <stroke
 | 
			
		||||
        android:width="1dp"
 | 
			
		||||
        android:color="@color/color_ff5c49" />
 | 
			
		||||
</shape>
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
 | 
			
		||||
    android:shape="rectangle">
 | 
			
		||||
    <corners android:radius="4dp" />
 | 
			
		||||
    <solid android:color="#263238" />
 | 
			
		||||
    <stroke
 | 
			
		||||
        android:width="1dp"
 | 
			
		||||
        android:color="@color/white" />
 | 
			
		||||
</shape>
 | 
			
		||||
							
								
								
									
										60
									
								
								app/src/main/res/layout/activity_original_work_detail.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/src/main/res/layout/activity_original_work_detail.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
 | 
			
		||||
    android:background="@color/black"
 | 
			
		||||
    android:orientation="vertical">
 | 
			
		||||
 | 
			
		||||
    <!-- 상단 배경 이미지 (가로를 가득, 306:432 비율, Blur는 코드에서 적용) -->
 | 
			
		||||
    <ImageView
 | 
			
		||||
        android:id="@+id/iv_bg"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:contentDescription="@null"
 | 
			
		||||
        android:scaleType="centerCrop"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent"
 | 
			
		||||
        tools:src="@drawable/bg_placeholder" />
 | 
			
		||||
 | 
			
		||||
    <View
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="0dp"
 | 
			
		||||
        android:background="@color/color_99000000"
 | 
			
		||||
        app:layout_constraintBottom_toBottomOf="parent"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent" />
 | 
			
		||||
 | 
			
		||||
    <RelativeLayout
 | 
			
		||||
        android:id="@+id/rl_toolbar"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="56dp"
 | 
			
		||||
        android:paddingHorizontal="24dp"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent">
 | 
			
		||||
 | 
			
		||||
        <ImageView
 | 
			
		||||
            android:id="@+id/iv_back"
 | 
			
		||||
            android:layout_width="24dp"
 | 
			
		||||
            android:layout_height="24dp"
 | 
			
		||||
            android:layout_centerVertical="true"
 | 
			
		||||
            android:contentDescription="@null"
 | 
			
		||||
            android:src="@drawable/ic_back" />
 | 
			
		||||
 | 
			
		||||
    </RelativeLayout>
 | 
			
		||||
 | 
			
		||||
    <androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
        android:id="@+id/rv_detail"
 | 
			
		||||
        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" />
 | 
			
		||||
</androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
							
								
								
									
										50
									
								
								app/src/main/res/layout/item_original_detail_character.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								app/src/main/res/layout/item_original_detail_character.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<androidx.constraintlayout.widget.ConstraintLayout 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">
 | 
			
		||||
 | 
			
		||||
    <ImageView
 | 
			
		||||
        android:id="@+id/iv_character"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="0dp"
 | 
			
		||||
        android:contentDescription="@null"
 | 
			
		||||
        android:scaleType="centerCrop"
 | 
			
		||||
        app:layout_constraintDimensionRatio="1:1"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent"
 | 
			
		||||
        tools:src="@drawable/ic_logo_service_center" />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_character_name"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginTop="4dp"
 | 
			
		||||
        android:ellipsize="end"
 | 
			
		||||
        android:fontFamily="@font/pretendard_bold"
 | 
			
		||||
        android:maxLines="1"
 | 
			
		||||
        android:textColor="@color/white"
 | 
			
		||||
        android:textSize="18sp"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/iv_character"
 | 
			
		||||
        tools:text="캐릭터 이름" />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_character_description"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginTop="2dp"
 | 
			
		||||
        android:ellipsize="end"
 | 
			
		||||
        android:fontFamily="@font/pretendard_regular"
 | 
			
		||||
        android:maxLines="1"
 | 
			
		||||
        android:textColor="#78909C"
 | 
			
		||||
        android:textSize="14sp"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/tv_character_name"
 | 
			
		||||
        tools:text="설명 텍스트" />
 | 
			
		||||
 | 
			
		||||
</androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
							
								
								
									
										113
									
								
								app/src/main/res/layout/item_original_detail_header.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								app/src/main/res/layout/item_original_detail_header.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,113 @@
 | 
			
		||||
<?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>
 | 
			
		||||
@@ -9,40 +9,44 @@
 | 
			
		||||
        android:id="@+id/iv_cover"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="0dp"
 | 
			
		||||
        android:scaleType="centerCrop"
 | 
			
		||||
        android:contentDescription="@null"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        android:scaleType="centerCrop"
 | 
			
		||||
        app:layout_constraintDimensionRatio="306:432"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent"
 | 
			
		||||
        tools:src="@drawable/ic_logo_service_center" />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_title"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginTop="6dp"
 | 
			
		||||
        android:layout_marginTop="4dp"
 | 
			
		||||
        android:ellipsize="end"
 | 
			
		||||
        android:fontFamily="@font/pretendard_regular"
 | 
			
		||||
        android:includeFontPadding="false"
 | 
			
		||||
        android:maxLines="1"
 | 
			
		||||
        android:textColor="@color/color_b0bec5"
 | 
			
		||||
        android:textSize="16sp"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/iv_cover"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        android:textColor="@color/white"
 | 
			
		||||
        android:textSize="18sp"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/iv_cover"
 | 
			
		||||
        tools:text="작품 제목" />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_content_type"
 | 
			
		||||
        android:layout_width="0dp"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginTop="2dp"
 | 
			
		||||
        android:layout_marginTop="4dp"
 | 
			
		||||
        android:ellipsize="end"
 | 
			
		||||
        android:fontFamily="@font/pretendard_regular"
 | 
			
		||||
        android:includeFontPadding="false"
 | 
			
		||||
        android:maxLines="1"
 | 
			
		||||
        android:textColor="#78909C"
 | 
			
		||||
        android:textSize="14sp"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/tv_title"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@id/tv_title"
 | 
			
		||||
        tools:text="Audio Drama" />
 | 
			
		||||
 | 
			
		||||
</androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user