feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현
This commit is contained in:
@@ -192,6 +192,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name=".chat.character.newcharacters.NewCharactersAllActivity" />
|
<activity android:name=".chat.character.newcharacters.NewCharactersAllActivity" />
|
||||||
|
<activity android:name=".chat.original.detail.OriginalWorkDetailActivity" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
|
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
package kr.co.vividnext.sodalive.chat.original
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
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.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.gson.Gson
|
||||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
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.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.databinding.FragmentOriginalTabBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
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
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
class OriginalTabFragment :
|
class OriginalTabFragment :
|
||||||
BaseFragment<FragmentOriginalTabBinding>(FragmentOriginalTabBinding::inflate) {
|
BaseFragment<FragmentOriginalTabBinding>(FragmentOriginalTabBinding::inflate) {
|
||||||
|
|
||||||
private val viewModel: OriginalWorkViewModel by inject()
|
private val viewModel: OriginalWorkViewModel by inject()
|
||||||
|
private val myPageViewModel: MyPageViewModel by inject()
|
||||||
|
|
||||||
private lateinit var adapter: OriginalWorkListAdapter
|
private lateinit var adapter: OriginalWorkListAdapter
|
||||||
|
|
||||||
|
private lateinit var loadingDialog: LoadingDialog
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
||||||
|
|
||||||
setupRecycler()
|
setupRecycler()
|
||||||
bind()
|
bind()
|
||||||
viewModel.loadMore()
|
viewModel.loadMore()
|
||||||
@@ -28,7 +51,18 @@ class OriginalTabFragment :
|
|||||||
private fun setupRecycler() {
|
private fun setupRecycler() {
|
||||||
val spanCount = 3
|
val spanCount = 3
|
||||||
val spacingPx = 16f.dpToPx().toInt()
|
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.layoutManager = GridLayoutManager(requireContext(), spanCount)
|
||||||
binding.rvOriginal.addItemDecoration(
|
binding.rvOriginal.addItemDecoration(
|
||||||
GridSpacingItemDecoration(
|
GridSpacingItemDecoration(
|
||||||
@@ -56,6 +90,68 @@ class OriginalTabFragment :
|
|||||||
// 누적 리스트를 어댑터에 추가
|
// 누적 리스트를 어댑터에 추가
|
||||||
adapter.addItems(list.drop(adapter.itemCount))
|
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 kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface OriginalWorkApi {
|
interface OriginalWorkApi {
|
||||||
@@ -13,4 +14,18 @@ interface OriginalWorkApi {
|
|||||||
@Query("page") page: Int,
|
@Query("page") page: Int,
|
||||||
@Query("size") size: Int
|
@Query("size") size: Int
|
||||||
): Single<ApiResponse<OriginalWorkListResponse>>
|
): 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(
|
class OriginalWorkRepository(
|
||||||
private val api: OriginalWorkApi
|
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)
|
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
|
||||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid 간격 데코레이션. 헤더가 있는 경우 headerCount로 보정하여 첫 행 판단 및 컬럼 계산을 정확히 수행한다.
|
||||||
|
*/
|
||||||
class GridSpacingItemDecoration(
|
class GridSpacingItemDecoration(
|
||||||
private val spanCount: Int,
|
private val spanCount: Int,
|
||||||
private val spacing: Int,
|
private val spacing: Int,
|
||||||
private val includeEdge: Boolean
|
private val includeEdge: Boolean,
|
||||||
|
private val headerCount: Int = 0
|
||||||
) : ItemDecoration() {
|
) : ItemDecoration() {
|
||||||
override fun getItemOffsets(
|
override fun getItemOffsets(
|
||||||
outRect: Rect,
|
outRect: Rect,
|
||||||
@@ -17,19 +20,26 @@ class GridSpacingItemDecoration(
|
|||||||
parent: RecyclerView,
|
parent: RecyclerView,
|
||||||
state: RecyclerView.State
|
state: RecyclerView.State
|
||||||
) {
|
) {
|
||||||
val position = parent.getChildAdapterPosition(view) // Item position
|
val position = parent.getChildAdapterPosition(view)
|
||||||
val column = position % spanCount // Current column
|
// 헤더 범위는 간격을 적용하지 않음
|
||||||
|
val adjustedPosition = position - headerCount
|
||||||
|
if (adjustedPosition < 0) {
|
||||||
|
outRect.set(0, 0, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val column = adjustedPosition % spanCount
|
||||||
if (includeEdge) {
|
if (includeEdge) {
|
||||||
outRect.left = spacing - column * spacing / spanCount
|
outRect.left = spacing - column * spacing / spanCount
|
||||||
outRect.right = (column + 1) * spacing / spanCount
|
outRect.right = (column + 1) * spacing / spanCount
|
||||||
if (position < spanCount) { // Top edge
|
if (adjustedPosition < spanCount) { // Top edge (헤더 제외 첫 행)
|
||||||
outRect.top = spacing
|
outRect.top = spacing
|
||||||
}
|
}
|
||||||
outRect.bottom = spacing // Item bottom
|
outRect.bottom = spacing // Item bottom
|
||||||
} else {
|
} else {
|
||||||
outRect.left = column * spacing / spanCount
|
outRect.left = column * spacing / spanCount
|
||||||
outRect.right = spacing - (column + 1) * spacing / spanCount
|
outRect.right = spacing - (column + 1) * spacing / spanCount
|
||||||
if (position >= spanCount) {
|
if (adjustedPosition >= spanCount) {
|
||||||
outRect.top = spacing // Item top
|
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 { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) }
|
||||||
viewModel { NewCharactersAllViewModel(get()) }
|
viewModel { NewCharactersAllViewModel(get()) }
|
||||||
viewModel { OriginalWorkViewModel(get()) }
|
viewModel { OriginalWorkViewModel(get()) }
|
||||||
|
viewModel { kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailViewModel(get()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val repositoryModule = module {
|
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:id="@+id/iv_cover"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:scaleType="centerCrop"
|
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
android:scaleType="centerCrop"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintDimensionRatio="306:432"
|
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" />
|
tools:src="@drawable/ic_logo_service_center" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_title"
|
android:id="@+id/tv_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="4dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:fontFamily="@font/pretendard_regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textColor="@color/color_b0bec5"
|
android:textColor="@color/white"
|
||||||
android:textSize="16sp"
|
android:textSize="18sp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/iv_cover"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/iv_cover"
|
||||||
tools:text="작품 제목" />
|
tools:text="작품 제목" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_content_type"
|
android:id="@+id/tv_content_type"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="2dp"
|
android:layout_marginTop="4dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:fontFamily="@font/pretendard_regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textColor="#78909C"
|
android:textColor="#78909C"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
app:layout_constraintTop_toBottomOf="@id/tv_title"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tv_title"
|
||||||
tools:text="Audio Drama" />
|
tools:text="Audio Drama" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user