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.R
|
||||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterTabFragment
|
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.chat.talk.TalkTabFragment
|
||||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
import kr.co.vividnext.sodalive.databinding.FragmentChatBinding
|
import kr.co.vividnext.sodalive.databinding.FragmentChatBinding
|
||||||
@@ -52,6 +53,7 @@ class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::infl
|
|||||||
private fun setupTabs() {
|
private fun setupTabs() {
|
||||||
// 탭 추가
|
// 탭 추가
|
||||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("캐릭터"))
|
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("캐릭터"))
|
||||||
|
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) {
|
val fragment = when (position) {
|
||||||
0 -> CharacterTabFragment()
|
0 -> CharacterTabFragment()
|
||||||
1 -> TalkTabFragment()
|
1 -> OriginalTabFragment()
|
||||||
|
2 -> TalkTabFragment()
|
||||||
else -> CharacterTabFragment()
|
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.TalkTabRepository
|
||||||
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
|
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
|
||||||
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
|
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.ApiBuilder
|
||||||
import kr.co.vividnext.sodalive.common.ObjectBox
|
import kr.co.vividnext.sodalive.common.ObjectBox
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerApi
|
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(), CharacterApi::class.java) }
|
||||||
single { ApiBuilder().build(get(), TalkApi::class.java) }
|
single { ApiBuilder().build(get(), TalkApi::class.java) }
|
||||||
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
|
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
|
||||||
|
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModelModule = module {
|
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.CharacterCommentListViewModel(get()) }
|
||||||
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()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val repositoryModule = module {
|
private val repositoryModule = module {
|
||||||
@@ -417,6 +422,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
|||||||
factory { TalkTabRepository(get()) }
|
factory { TalkTabRepository(get()) }
|
||||||
factory { CharacterCommentRepository(get()) }
|
factory { CharacterCommentRepository(get()) }
|
||||||
factory { NewCharactersRepository(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