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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
// 필요 시 로딩/토스트 처리 추가
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
18
app/src/main/res/layout/fragment_original_tab.xml
Normal file
18
app/src/main/res/layout/fragment_original_tab.xml
Normal 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>
|
||||
48
app/src/main/res/layout/item_original_work.xml
Normal file
48
app/src/main/res/layout/item_original_work.xml
Normal 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>
|
||||
Reference in New Issue
Block a user