feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현

This commit is contained in:
2025-09-15 18:57:26 +09:00
parent f15c6be1a4
commit dcde2b125e
18 changed files with 796 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?: "알 수 없는 오류가 발생했습니다."
})
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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