From 05208d3031ae7fadabd026a15a0da6efa5737b39 Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 12 Sep 2025 19:44:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-character):=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A0=84=EC=B2=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../sodalive/chat/character/CharacterApi.kt | 8 ++ .../chat/character/CharacterTabFragment.kt | 9 ++ .../newcharacters/NewCharactersAllActivity.kt | 92 +++++++++++++++++++ .../newcharacters/NewCharactersAllAdapter.kt | 55 +++++++++++ .../NewCharactersAllViewModel.kt | 68 ++++++++++++++ .../newcharacters/NewCharactersRepository.kt | 21 +++++ .../newcharacters/RecentCharactersResponse.kt | 11 +++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 4 + .../layout/activity_new_characters_all.xml | 61 ++++++++++++ .../res/layout/fragment_character_tab.xml | 3 +- .../res/layout/item_new_character_all.xml | 52 +++++++++++ 12 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/RecentCharactersResponse.kt create mode 100644 app/src/main/res/layout/activity_new_characters_all.xml create mode 100644 app/src/main/res/layout/item_new_character_all.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 40997222..9bd82503 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -191,6 +191,7 @@ + > + // 신규 캐릭터 전체보기 + @GET("/api/chat/character/recent") + fun getRecentCharacters( + @Header("Authorization") authHeader: String, + @Query("page") page: Int, + @Query("size") size: Int + ): Single> + @POST("/api/chat/character/image/purchase") fun purchaseCharacterImage( @Header("Authorization") authHeader: String, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt index 833aa987..6b9a8d3d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt @@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.base.SodaDialog import kr.co.vividnext.sodalive.chat.character.curation.CurationSectionAdapter 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.chat.character.newcharacters.NewCharactersAllActivity import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager @@ -289,6 +290,14 @@ class CharacterTabFragment : BaseFragment( recyclerView.adapter = newCharacterAdapter binding.tvNewCharacterAll.setOnClickListener { + ensureLoginAndAuth { + startActivity( + Intent( + requireContext(), + NewCharactersAllActivity::class.java + ) + ) + } } // 신규 캐릭터 LiveData 구독 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllActivity.kt new file mode 100644 index 00000000..c2941645 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllActivity.kt @@ -0,0 +1,92 @@ +package kr.co.vividnext.sodalive.chat.character.newcharacters + +import android.content.Intent +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.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.common.LoadingDialog +import kr.co.vividnext.sodalive.databinding.ActivityNewCharactersAllBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import org.koin.android.ext.android.inject + +class NewCharactersAllActivity : BaseActivity( + ActivityNewCharactersAllBinding::inflate +) { + private val viewModel: NewCharactersAllViewModel by inject() + + private lateinit var adapter: NewCharactersAllAdapter + private lateinit var loadingDialog: LoadingDialog + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupView() + bindData() + viewModel.loadMore() + } + + override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + binding.toolbar.tvBack.text = "신규 캐릭터 전체보기" + binding.toolbar.tvBack.setOnClickListener { finish() } + + val spanCount = 3 + val spacingPx = 8f.dpToPx().toInt() + + adapter = NewCharactersAllAdapter { characterId -> + startActivity( + Intent(this, CharacterDetailActivity::class.java).apply { + putExtra(EXTRA_CHARACTER_ID, characterId) + } + ) + } + + binding.rvCharacters.layoutManager = GridLayoutManager(this, spanCount) + binding.rvCharacters.addItemDecoration( + GridSpacingItemDecoration( + spanCount, + spacingPx, + false + ) + ) + binding.rvCharacters.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.rvCharacters.adapter = adapter + } + + private fun bindData() { + viewModel.isLoading.observe(this) { isLoading -> + if (isLoading) loadingDialog.show(screenWidth) else loadingDialog.dismiss() + } + + viewModel.totalCount.observe(this) { count -> + binding.tvTotalCount.text = "$count" + } + + viewModel.items.observe(this) { list -> + adapter.addItems(list.drop(adapter.itemCount)) + binding.rvCharacters.visibility = if (list.isEmpty()) View.GONE else View.VISIBLE + } + + viewModel.toastLiveData.observe(this) { message -> + message?.let { showToast(it) } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllAdapter.kt new file mode 100644 index 00000000..a7a2d568 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllAdapter.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.chat.character.newcharacters + +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.chat.character.Character +import kr.co.vividnext.sodalive.databinding.ItemNewCharacterAllBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class NewCharactersAllAdapter( + private val onClick: (Long) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + inner class VH(val binding: ItemNewCharacterAllBinding) : 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 { onClick(item.characterId) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val binding = ItemNewCharacterAllBinding.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]) + } + + fun addItems(newItems: List) { + val start = items.size + items.addAll(newItems) + notifyItemRangeInserted(start, newItems.size) + } + + @SuppressLint("NotifyDataSetChanged") + fun clear() { + items.clear() + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllViewModel.kt new file mode 100644 index 00000000..eb699a9f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersAllViewModel.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.chat.character.newcharacters + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +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.common.SharedPreferenceManager + +class NewCharactersAllViewModel( + private val repository: NewCharactersRepository +) : BaseViewModel() { + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData get() = _toastLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData get() = _isLoading + + private val _totalCount = MutableLiveData(0) + val totalCount: LiveData get() = _totalCount + + private val _items = MutableLiveData>(emptyList()) + val items: LiveData> 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.getRecentCharacters( + 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 { + _toastLiveData.value = response.message + ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + _isLoading.value = false + }, { e -> + _isLoading.value = false + e.message?.let { Logger.e(it) } + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + }) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersRepository.kt new file mode 100644 index 00000000..801b36eb --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/NewCharactersRepository.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.character.newcharacters + +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.chat.character.CharacterApi +import kr.co.vividnext.sodalive.common.ApiResponse + +class NewCharactersRepository( + private val api: CharacterApi +) { + fun getRecentCharacters( + token: String, + page: Int, + size: Int + ): Single> { + return api.getRecentCharacters( + authHeader = token, + page = page, + size = size + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/RecentCharactersResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/RecentCharactersResponse.kt new file mode 100644 index 00000000..93ae6ebb --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/newcharacters/RecentCharactersResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.chat.character.newcharacters + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.chat.character.Character + +@Keep +data class RecentCharactersResponse( + @SerializedName("totalCount") val totalCount: Long, + @SerializedName("content") val content: List +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 39c917c0..cd42f689 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -67,6 +67,8 @@ import kr.co.vividnext.sodalive.audition.role.AuditionRoleDetailViewModel import kr.co.vividnext.sodalive.chat.character.CharacterApi import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel +import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllViewModel +import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersRepository import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentApi import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailRepository @@ -364,6 +366,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { TalkTabViewModel(get()) } viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) } viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) } + viewModel { NewCharactersAllViewModel(get()) } } private val repositoryModule = module { @@ -413,6 +416,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { CharacterGalleryRepository(get()) } factory { TalkTabRepository(get()) } factory { CharacterCommentRepository(get()) } + factory { NewCharactersRepository(get()) } } diff --git a/app/src/main/res/layout/activity_new_characters_all.xml b/app/src/main/res/layout/activity_new_characters_all.xml new file mode 100644 index 00000000..ca6851b5 --- /dev/null +++ b/app/src/main/res/layout/activity_new_characters_all.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_character_tab.xml b/app/src/main/res/layout/fragment_character_tab.xml index d7c3e073..219a144f 100644 --- a/app/src/main/res/layout/fragment_character_tab.xml +++ b/app/src/main/res/layout/fragment_character_tab.xml @@ -160,8 +160,7 @@ android:fontFamily="@font/pretendard_regular" android:text="전체보기" android:textColor="#90A4AE" - android:textSize="14sp" - android:visibility="gone" /> + android:textSize="14sp" /> diff --git a/app/src/main/res/layout/item_new_character_all.xml b/app/src/main/res/layout/item_new_character_all.xml new file mode 100644 index 00000000..057f641b --- /dev/null +++ b/app/src/main/res/layout/item_new_character_all.xml @@ -0,0 +1,52 @@ + + + + + + + + + +