feat(chat-original): ChatFragment에 작품별 탭 및 리스트 UI/API 연동 추가

- ChatFragment에 '작품별' 탭 추가 및 프래그먼트 스위칭 로직 반영
- /api/chat/original/list API, 모델, 레포지토리, ViewModel 추가
- OriginalTabFragment/Adapter/레이아웃 구현 (3단 그리드, 간격 16dp, 이미지 라운드 16dp, 아이템 이미지의 레이아웃 비율을 306:432)
- 스크롤 끝 감지를 구현하여 무한 스크롤을 지원
This commit is contained in:
2025-09-15 16:21:54 +09:00
parent 05208d3031
commit f15c6be1a4
10 changed files with 314 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.CharacterTabFragment
import kr.co.vividnext.sodalive.chat.original.OriginalTabFragment
import kr.co.vividnext.sodalive.chat.talk.TalkTabFragment
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentChatBinding
@@ -52,6 +53,7 @@ class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::infl
private fun setupTabs() {
// 탭 추가
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("캐릭터"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("작품별"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(""))
// 탭 선택 리스너 설정
@@ -86,7 +88,8 @@ class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::infl
// 선택된 탭에 따라 프래그먼트 표시
val fragment = when (position) {
0 -> CharacterTabFragment()
1 -> TalkTabFragment()
1 -> OriginalTabFragment()
2 -> TalkTabFragment()
else -> CharacterTabFragment()
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.chat.original
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.databinding.FragmentOriginalTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class OriginalTabFragment :
BaseFragment<FragmentOriginalTabBinding>(FragmentOriginalTabBinding::inflate) {
private val viewModel: OriginalWorkViewModel by inject()
private lateinit var adapter: OriginalWorkListAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecycler()
bind()
viewModel.loadMore()
}
private fun setupRecycler() {
val spanCount = 3
val spacingPx = 16f.dpToPx().toInt()
adapter = OriginalWorkListAdapter { /* TODO: 상세 페이지 이동 정의 시 연결 */ }
binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount)
binding.rvOriginal.addItemDecoration(
GridSpacingItemDecoration(
spanCount,
spacingPx,
true
)
)
binding.rvOriginal.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager)
.findLastVisibleItemPosition()
val totalItemCount = recyclerView.adapter?.itemCount ?: 0
if (!recyclerView.canScrollVertically(1) && lastVisibleItemPosition >= totalItemCount - 1) {
viewModel.loadMore()
}
}
})
binding.rvOriginal.adapter = adapter
}
private fun bind() {
viewModel.items.observe(viewLifecycleOwner) { list ->
// 누적 리스트를 어댑터에 추가
adapter.addItems(list.drop(adapter.itemCount))
}
// 필요 시 로딩/토스트 처리 추가
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.chat.original
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface OriginalWorkApi {
@GET("/api/chat/original/list")
fun getOriginalWorkList(
@Header("Authorization") authHeader: String,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<OriginalWorkListResponse>>
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.chat.original
import android.annotation.SuppressLint
import android.view.LayoutInflater
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.databinding.ItemOriginalWorkBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OriginalWorkListAdapter(
private val onClick: (Long) -> Unit
) : RecyclerView.Adapter<OriginalWorkListAdapter.VH>() {
private val items = mutableListOf<OriginalWorkListItemResponse>()
inner class VH(val binding: ItemOriginalWorkBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: OriginalWorkListItemResponse) {
binding.tvTitle.text = item.title
binding.tvContentType.text = item.contentType
binding.ivCover.load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo_service_center)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener { onClick(item.id) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ItemOriginalWorkBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return VH(binding)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
}
@SuppressLint("NotifyDataSetChanged")
fun submitList(newItems: List<OriginalWorkListItemResponse>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun addItems(newItems: List<OriginalWorkListItemResponse>) {
val start = items.size
items.addAll(newItems)
notifyItemRangeInserted(start, newItems.size)
}
@SuppressLint("NotifyDataSetChanged")
fun clear() {
items.clear()
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.chat.original
import androidx.annotation.Keep
@Keep
data class OriginalWorkListResponse(
val totalCount: Long,
val content: List<OriginalWorkListItemResponse>
)
@Keep
data class OriginalWorkListItemResponse(
val id: Long,
val imageUrl: String?,
val title: String,
val contentType: String
)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.chat.original
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
class OriginalWorkRepository(
private val api: OriginalWorkApi
) {
fun getOriginalWorks(token: String, page: Int, size: Int): Single<ApiResponse<OriginalWorkListResponse>> {
return api.getOriginalWorkList(token, page, size)
}
}

View File

@@ -0,0 +1,71 @@
package kr.co.vividnext.sodalive.chat.original
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.common.SharedPreferenceManager
class OriginalWorkViewModel(
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 _totalCount = MutableLiveData<Long>(0)
val totalCount: LiveData<Long> get() = _totalCount
private val _items = MutableLiveData<List<OriginalWorkListItemResponse>>(emptyList())
val items: LiveData<List<OriginalWorkListItemResponse>> get() = _items
private var page = 0
private val size = 20
private var isLast = false
fun loadMore() {
if (_isLoading.value == true || isLast) return
_isLoading.value = true
compositeDisposable.add(
repository.getOriginalWorks(
token = "Bearer ${SharedPreferenceManager.token}",
page = page,
size = size
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val data = response.data
if (response.success && data != null) {
val current = _items.value ?: emptyList()
val next = current + data.content
_items.value = next
_totalCount.value = data.totalCount
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 ?: "알 수 없는 오류가 발생했습니다."
})
)
}
fun refresh() {
page = 0
isLast = false
_items.value = emptyList()
loadMore()
}
}

View File

@@ -79,6 +79,9 @@ import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
import kr.co.vividnext.sodalive.chat.original.OriginalWorkApi
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkViewModel
import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.common.ObjectBox
import kr.co.vividnext.sodalive.explorer.ExplorerApi
@@ -263,6 +266,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), CharacterApi::class.java) }
single { ApiBuilder().build(get(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
}
private val viewModelModule = module {
@@ -367,6 +371,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) }
viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) }
viewModel { NewCharactersAllViewModel(get()) }
viewModel { OriginalWorkViewModel(get()) }
}
private val repositoryModule = module {
@@ -417,6 +422,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { TalkTabRepository(get()) }
factory { CharacterCommentRepository(get()) }
factory { NewCharactersRepository(get()) }
factory { OriginalWorkRepository(get()) }
}

View File

@@ -0,0 +1,18 @@
<?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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_original"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/item_original_work" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,48 @@
<?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_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"
app:layout_constraintDimensionRatio="306:432"
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:ellipsize="end"
android:maxLines="1"
android:textColor="@color/color_b0bec5"
android:textSize="16sp"
app:layout_constraintTop_toBottomOf="@id/iv_cover"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="작품 제목" />
<TextView
android:id="@+id/tv_content_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
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"
tools:text="Audio Drama" />
</androidx.constraintlayout.widget.ConstraintLayout>