From c7af522cfbf850a4ab5ffc46c6cd9d707789b301 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 27 Mar 2025 06:04:24 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=80=EC=83=89=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 2 + .../home/AudioContentMainTabHomeFragment.kt | 7 + .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 6 + .../sodalive/search/SearchActivity.kt | 556 ++++++++++++++++++ .../sodalive/search/SearchAdapter.kt | 97 +++ .../co/vividnext/sodalive/search/SearchApi.kt | 48 ++ .../sodalive/search/SearchRepository.kt | 58 ++ .../sodalive/search/SearchResponse.kt | 33 ++ .../sodalive/search/SearchViewModel.kt | 250 ++++++++ app/src/main/res/layout/activity_search.xml | 219 +++++++ app/src/main/res/layout/item_search.xml | 57 ++ 12 files changed, 1335 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchApi.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/search/SearchViewModel.kt create mode 100644 app/src/main/res/layout/activity_search.xml create mode 100644 app/src/main/res/layout/item_search.xml diff --git a/app/build.gradle b/app/build.gradle index 5006f3c..1be2bf7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,8 +35,8 @@ android { applicationId "kr.co.vividnext.sodalive" minSdk 23 targetSdk 34 - versionCode 154 - versionName "1.31.1" + versionCode 156 + versionName "1.32.0" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4d64259..6720e17 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -174,6 +174,8 @@ + + diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/main/v2/home/AudioContentMainTabHomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/main/v2/home/AudioContentMainTabHomeFragment.kt index 5483dd5..146396e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/main/v2/home/AudioContentMainTabHomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/main/v2/home/AudioContentMainTabHomeFragment.kt @@ -46,6 +46,7 @@ import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.live.event_banner.EventBannerAdapter import kr.co.vividnext.sodalive.main.MainActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity +import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.notice.NoticeDetailActivity import kr.co.vividnext.sodalive.settings.notification.MemberRole @@ -154,6 +155,12 @@ class AudioContentMainTabHomeFragment : BaseFragment(ActivitySearchBinding::inflate) { + + private val viewModel: SearchViewModel by inject() + + private lateinit var imm: InputMethodManager + private lateinit var loadingDialog: LoadingDialog + + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var unifiedChannelAdapter: SearchAdapter + private lateinit var unifiedContentAdapter: SearchAdapter + private lateinit var unifiedSeriesAdapter: SearchAdapter + + private lateinit var channelAdapter: SearchAdapter + private lateinit var contentAdapter: SearchAdapter + private lateinit var seriesAdapter: SearchAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imm = getSystemService( + Service.INPUT_METHOD_SERVICE + ) as InputMethodManager + + binding.etSearch.requestFocus() + handler.postDelayed({ + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.etSearch, InputMethodManager.SHOW_IMPLICIT) + }, 500) + + bindData() + } + + override fun onPause() { + hideKeyboard() + super.onPause() + } + + override fun setupView() { + loadingDialog = LoadingDialog(this, layoutInflater) + binding.ivBack.setOnClickListener { finish() } + + setupTabs() + setupUnifiedView() + setupChannelListView() + setupContentListView() + setupSeriesListView() + } + + private fun setupChannelListView() { + channelAdapter = SearchAdapter { clickItem(it) } + + binding.rvCreator.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + + binding.rvCreator.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + + binding.rvCreator.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager + if ( + layoutManager != null && + layoutManager + .findLastCompletelyVisibleItemPosition() == channelAdapter.itemCount - 1 + ) { + viewModel.searchCreatorList() + } + } + }) + + binding.rvCreator.adapter = channelAdapter + } + + private fun setupContentListView() { + contentAdapter = SearchAdapter { clickItem(it) } + + binding.rvContent.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + + binding.rvContent.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + + binding.rvContent.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager + if ( + layoutManager != null && + layoutManager + .findLastCompletelyVisibleItemPosition() == contentAdapter.itemCount - 1 + ) { + viewModel.searchContentList() + } + } + }) + + binding.rvContent.adapter = contentAdapter + } + + private fun setupSeriesListView() { + seriesAdapter = SearchAdapter { clickItem(it) } + + binding.rvSeries.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + + binding.rvSeries.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + + binding.rvSeries.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager + if ( + layoutManager != null && + layoutManager + .findLastCompletelyVisibleItemPosition() == seriesAdapter.itemCount - 1 + ) { + viewModel.searchSeriesList() + } + } + }) + + binding.rvSeries.adapter = seriesAdapter + } + + private fun setupUnifiedView() { + unifiedChannelAdapter = SearchAdapter { clickItem(it) } + unifiedContentAdapter = SearchAdapter { clickItem(it) } + unifiedSeriesAdapter = SearchAdapter { clickItem(it) } + + binding.tvMoreCreator.setOnClickListener { + viewModel.changeTab(SearchViewModel.SearchPageTab.CREATOR) + } + + binding.tvMoreContent.setOnClickListener { + viewModel.changeTab(SearchViewModel.SearchPageTab.CONTENT) + } + + binding.tvMoreSeries.setOnClickListener { + viewModel.changeTab(SearchViewModel.SearchPageTab.SERIES) + } + + binding.rvUnifiedCreator.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + binding.rvUnifiedCreator.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + binding.rvUnifiedCreator.adapter = unifiedChannelAdapter + + binding.rvUnifiedContent.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + binding.rvUnifiedContent.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + binding.rvUnifiedContent.adapter = unifiedContentAdapter + + binding.rvUnifiedSeries.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + binding.rvUnifiedSeries.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + outRect.left = 13.3f.dpToPx().toInt() + outRect.right = 13.3f.dpToPx().toInt() + outRect.top = 13.3f.dpToPx().toInt() + outRect.bottom = 13.3f.dpToPx().toInt() + } + }) + binding.rvUnifiedSeries.adapter = unifiedSeriesAdapter + } + + @OptIn(UnstableApi::class) + private fun clickItem(item: SearchResponseItem) { + hideKeyboard() + + startActivity( + when (item.type) { + SearchResponseType.CREATOR -> { + Intent(applicationContext, UserProfileActivity::class.java).apply { + putExtra(Constants.EXTRA_USER_ID, item.id) + } + } + + SearchResponseType.CONTENT -> { + Intent(applicationContext, AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, item.id) + } + } + + SearchResponseType.SERIES -> { + Intent(applicationContext, SeriesDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_SERIES_ID, item.id) + } + } + } + ) + } + + private fun setupTabs() { + val tabs = binding.tabs + val tabTitles = listOf("통합", "채널", "콘텐츠", "시리즈") + for (title in tabTitles) { + tabs.addTab(tabs.newTab().setText(title)) + } + + tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val selectedTab = SearchViewModel.SearchPageTab.fromOrdinal(tab.position) + viewModel.changeTab(selectedTab!!) + + tab.view.isSelected = true + hideKeyboard() + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + tab.view.isSelected = false + } + + override fun onTabReselected(tab: TabLayout.Tab) { + } + }) + } + + @SuppressLint("NotifyDataSetChanged") + private fun bindData() { + compositeDisposable.add( + binding.etSearch.textChanges().skip(1) + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribe { + channelAdapter.clear() + contentAdapter.clear() + seriesAdapter.clear() + + unifiedChannelAdapter.clear() + unifiedContentAdapter.clear() + unifiedSeriesAdapter.clear() + + viewModel.keyword = it.toString() + if (it.length >= 2) { + viewModel.searchUnified() + binding.tabs.visibility = View.VISIBLE + binding.tvResultX.visibility = View.GONE + } else { + binding.nsSearchUnified.visibility = View.GONE + binding.rvCreator.visibility = View.GONE + binding.rvContent.visibility = View.GONE + binding.rvSeries.visibility = View.GONE + binding.tabs.visibility = View.GONE + binding.tvResultX.visibility = View.GONE + } + } + ) + + viewModel.toastLiveData.observe(this) { + it?.let { Toast.makeText(this@SearchActivity, it, Toast.LENGTH_LONG).show() } + } + + viewModel.isLoading.observe(this) { + if (it) { + loadingDialog.show(screenWidth, "") + } else { + loadingDialog.dismiss() + } + } + + viewModel.searchUnifiedLiveData.observe(this) { + if ( + it.creatorList.isEmpty() && + it.seriesList.isEmpty() && + it.contentList.isEmpty() + ) { + binding.tabs.visibility = View.GONE + hideAllView() + if (viewModel.keyword.isNotBlank()) { + binding.tvResultX.visibility = View.VISIBLE + } else { + binding.tvResultX.visibility = View.GONE + } + } else { + binding.tabs.visibility = View.VISIBLE + binding.nsSearchUnified.visibility = View.VISIBLE + + if (it.creatorList.isNotEmpty()) { + binding.tvCreatorTitle.visibility = View.VISIBLE + binding.tvMoreCreator.visibility = View.VISIBLE + binding.rvUnifiedCreator.visibility = View.VISIBLE + unifiedChannelAdapter.items.addAll(it.creatorList) + unifiedChannelAdapter.notifyDataSetChanged() + } else { + binding.tvCreatorTitle.visibility = View.GONE + binding.tvMoreCreator.visibility = View.GONE + binding.rvUnifiedCreator.visibility = View.GONE + } + + if (it.contentList.isNotEmpty()) { + binding.tvContentTitle.visibility = View.VISIBLE + binding.tvMoreContent.visibility = View.VISIBLE + binding.rvUnifiedContent.visibility = View.VISIBLE + unifiedContentAdapter.items.addAll(it.contentList) + unifiedContentAdapter.notifyDataSetChanged() + } else { + binding.tvContentTitle.visibility = View.GONE + binding.tvMoreContent.visibility = View.GONE + binding.rvUnifiedContent.visibility = View.GONE + } + + if (it.seriesList.isNotEmpty()) { + binding.tvSeriesTitle.visibility = View.VISIBLE + binding.tvMoreSeries.visibility = View.VISIBLE + binding.rvUnifiedSeries.visibility = View.VISIBLE + unifiedSeriesAdapter.items.addAll(it.seriesList) + unifiedSeriesAdapter.notifyDataSetChanged() + } else { + binding.tvSeriesTitle.visibility = View.GONE + binding.tvMoreSeries.visibility = View.GONE + binding.rvUnifiedSeries.visibility = View.GONE + } + } + } + + viewModel.searchCreatorLiveData.observe(this) { + channelAdapter.items.addAll(it) + channelAdapter.notifyDataSetChanged() + + hideAllView() + if (channelAdapter.items.isEmpty()) { + if (viewModel.keyword.isNotBlank()) { + binding.tvResultX.visibility = View.VISIBLE + } else { + binding.tvResultX.visibility = View.GONE + } + } else { + binding.rvCreator.visibility = View.VISIBLE + } + } + + viewModel.searchContentLiveData.observe(this) { + contentAdapter.items.addAll(it) + contentAdapter.notifyDataSetChanged() + + hideAllView() + if (contentAdapter.items.isEmpty()) { + if (viewModel.keyword.isNotBlank()) { + binding.tvResultX.visibility = View.VISIBLE + } else { + binding.tvResultX.visibility = View.GONE + } + } else { + binding.rvContent.visibility = View.VISIBLE + } + } + + viewModel.searchSeriesLiveData.observe(this) { + seriesAdapter.items.addAll(it) + seriesAdapter.notifyDataSetChanged() + + hideAllView() + if (seriesAdapter.items.isEmpty()) { + if (viewModel.keyword.isNotBlank()) { + binding.tvResultX.visibility = View.VISIBLE + } else { + binding.tvResultX.visibility = View.GONE + } + } else { + binding.rvSeries.visibility = View.VISIBLE + } + } + + viewModel.currentTabLiveData.observe(this) { currentTab -> + hideAllView() + + binding.tabs.getTabAt(currentTab.ordinal)?.select() + when (currentTab) { + SearchViewModel.SearchPageTab.CREATOR -> { + if (channelAdapter.items.isEmpty()) { + viewModel.searchCreatorList() + } else { + binding.rvCreator.visibility = View.VISIBLE + } + } + + SearchViewModel.SearchPageTab.CONTENT -> { + if (contentAdapter.items.isEmpty()) { + viewModel.searchContentList() + } else { + binding.rvContent.visibility = View.VISIBLE + } + } + + SearchViewModel.SearchPageTab.SERIES -> { + if (seriesAdapter.items.isEmpty()) { + viewModel.searchSeriesList() + } else { + binding.rvSeries.visibility = View.VISIBLE + } + } + + else -> { + if ( + unifiedChannelAdapter.items.isEmpty() && + unifiedContentAdapter.items.isEmpty() && + unifiedSeriesAdapter.items.isEmpty() + ) { + if (viewModel.keyword.isNotBlank()) { + binding.tvResultX.visibility = View.VISIBLE + } else { + binding.tvResultX.visibility = View.GONE + } + } else { + binding.nsSearchUnified.visibility = View.VISIBLE + } + } + } + } + } + + private fun hideAllView() { + binding.nsSearchUnified.visibility = View.GONE + binding.rvCreator.visibility = View.GONE + binding.rvContent.visibility = View.GONE + binding.rvSeries.visibility = View.GONE + binding.tvResultX.visibility = View.GONE + } + + private fun hideKeyboard() { + handler.postDelayed({ + imm.hideSoftInputFromWindow( + window.decorView.applicationWindowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + }, 100) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchAdapter.kt new file mode 100644 index 0000000..2d3baae --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchAdapter.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.search + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.transform.CircleCropTransformation +import coil.transform.RoundedCornersTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemSearchBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class SearchAdapter( + private val onClickItem: (SearchResponseItem) -> Unit +) : RecyclerView.Adapter() { + inner class ViewHolder( + private val context: Context, + private val binding: ItemSearchBinding + ) : RecyclerView.ViewHolder(binding.root) { + + @SuppressLint("CheckResult") + fun bind(item: SearchResponseItem) { + binding.ivProfile.visibility = View.GONE + binding.ivContent.visibility = View.GONE + binding.ivSeries.visibility = View.GONE + binding.tvNickname.visibility = View.GONE + + when (item.type) { + SearchResponseType.CREATOR -> { + binding.ivProfile.visibility = View.VISIBLE + binding.ivProfile.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(CircleCropTransformation()) + } + } + + SearchResponseType.CONTENT -> { + binding.ivContent.visibility = View.VISIBLE + binding.ivContent.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(5.3f.dpToPx())) + } + binding.tvNickname.visibility = View.VISIBLE + } + + SearchResponseType.SERIES -> { + binding.ivSeries.visibility = View.VISIBLE + binding.ivSeries.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(5.3f.dpToPx())) + } + + binding.ivSeries.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(5.3f.dpToPx())) + } + binding.tvNickname.visibility = View.VISIBLE + } + } + + binding.tvTitle.text = item.title + binding.tvNickname.text = item.nickname + + binding.root.setOnClickListener { onClickItem(item) } + } + } + + val items: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + parent.context, + ItemSearchBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + @SuppressLint("NotifyDataSetChanged") + fun clear() { + items.clear() + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchApi.kt new file mode 100644 index 0000000..a89b77e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchApi.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.search + +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.settings.ContentType +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface SearchApi { + @GET("/search") + fun searchUnified( + @Query("keyword") keyword: String, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/search/creators") + fun searchCreatorList( + @Query("keyword") keyword: String, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/search/contents") + fun searchContentList( + @Query("keyword") keyword: String, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/search/series") + fun searchSeriesList( + @Query("keyword") keyword: String, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchRepository.kt new file mode 100644 index 0000000..2225897 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchRepository.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.search + +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.settings.ContentType + +class SearchRepository(private val api: SearchApi) { + fun searchUnified( + keyword: String, + token: String + ) = api.searchUnified( + keyword = keyword, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.values()[SharedPreferenceManager.contentPreference], + authHeader = token + ) + + fun searchCreatorList( + keyword: String, + page: Int, + size: Int, + token: String + ) = api.searchCreatorList( + keyword = keyword, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.values()[SharedPreferenceManager.contentPreference], + page = page - 1, + size = size, + authHeader = token + ) + + fun searchContentList( + keyword: String, + page: Int, + size: Int, + token: String + ) = api.searchContentList( + keyword = keyword, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.values()[SharedPreferenceManager.contentPreference], + page = page - 1, + size = size, + authHeader = token + ) + + fun searchSeriesList( + keyword: String, + page: Int, + size: Int, + token: String + ) = api.searchSeriesList( + keyword = keyword, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.values()[SharedPreferenceManager.contentPreference], + page = page - 1, + size = size, + authHeader = token + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchResponse.kt new file mode 100644 index 0000000..b612ceb --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchResponse.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.search + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class SearchUnifiedResponse( + @SerializedName("creatorList") val creatorList: List, + @SerializedName("contentList") val contentList: List, + @SerializedName("seriesList") val seriesList: List +) + +@Keep +data class SearchResponse( + @SerializedName("totalCount") val totalCount: Int, + @SerializedName("items") val items: List +) + +@Keep +data class SearchResponseItem( + @SerializedName("id") val id: Long, + @SerializedName("imageUrl") val imageUrl: String, + @SerializedName("title") val title: String, + @SerializedName("nickname") val nickname: String, + @SerializedName("type") val type: SearchResponseType +) + +@Keep +enum class SearchResponseType { + @SerializedName("CREATOR") CREATOR, + @SerializedName("CONTENT") CONTENT, + @SerializedName("SERIES") SERIES +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchViewModel.kt new file mode 100644 index 0000000..781516d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchViewModel.kt @@ -0,0 +1,250 @@ +package kr.co.vividnext.sodalive.search + +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.common.SharedPreferenceManager + +class SearchViewModel( + private val repository: SearchRepository +) : BaseViewModel() { + enum class SearchPageTab { + UNIFIED, CREATOR, CONTENT, SERIES; + + companion object { + fun fromOrdinal(ordinal: Int): SearchPageTab? { + return SearchPageTab.values().getOrNull(ordinal) + } + } + } + + var keyword = "" + + private val _currentTabLiveData = MutableLiveData(SearchPageTab.UNIFIED) + val currentTabLiveData: LiveData + get() = _currentTabLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private var _searchUnifiedLiveData = MutableLiveData() + val searchUnifiedLiveData: LiveData + get() = _searchUnifiedLiveData + + private var _searchCreatorLiveData = MutableLiveData>() + val searchCreatorLiveData: LiveData> + get() = _searchCreatorLiveData + + private var _searchContentLiveData = MutableLiveData>() + val searchContentLiveData: LiveData> + get() = _searchContentLiveData + + private var _searchSeriesLiveData = MutableLiveData>() + val searchSeriesLiveData: LiveData> + get() = _searchSeriesLiveData + + private var searchCreatorPage = 1 + private var searchContentPage = 1 + private var searchSeriesPage = 1 + + private var isSearchCreatorLast = false + private var isSearchContentLast = false + private var isSearchSeriesLast = false + + private val size = 20 + + fun changeTab(tab: SearchPageTab) { + _currentTabLiveData.value = tab + } + + fun searchUnified() { + if (!_isLoading.value!!) { + _currentTabLiveData.value = SearchPageTab.UNIFIED + + searchCreatorPage = 1 + searchContentPage = 1 + searchSeriesPage = 1 + + isSearchCreatorLast = false + isSearchContentLast = false + isSearchSeriesLast = false + + _isLoading.value = true + compositeDisposable.add( + repository.searchUnified( + keyword = keyword, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + _searchUnifiedLiveData.value = it.data!! + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } + } + + fun searchCreatorList() { + if (!_isLoading.value!! && !isSearchCreatorLast) { + _isLoading.value = true + compositeDisposable.add( + repository.searchCreatorList( + keyword = keyword, + page = searchCreatorPage, + size = size, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + searchCreatorPage += 1 + + val data = it.data + _searchCreatorLiveData.value = data.items + + if (data.items.isEmpty()) { + isSearchCreatorLast = true + } + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } else { + _searchCreatorLiveData.value = emptyList() + } + } + + fun searchContentList() { + if (!_isLoading.value!! && !isSearchContentLast) { + _isLoading.value = true + compositeDisposable.add( + repository.searchContentList( + keyword = keyword, + page = searchContentPage, + size = size, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + searchContentPage += 1 + + val data = it.data + _searchContentLiveData.value = data.items + + if (data.items.isEmpty()) { + isSearchContentLast = true + } + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } else { + _searchContentLiveData.value = emptyList() + } + } + + fun searchSeriesList() { + if (!_isLoading.value!! && !isSearchSeriesLast) { + _isLoading.value = true + compositeDisposable.add( + repository.searchSeriesList( + keyword = keyword, + page = searchSeriesPage, + size = size, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + searchSeriesPage += 1 + + val data = it.data + _searchSeriesLiveData.value = data.items + + if (data.items.isEmpty()) { + isSearchSeriesLast = true + } + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData + .value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } else { + _searchSeriesLiveData.value = emptyList() + } + } +} diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 0000000..bf6b0a7 --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_search.xml b/app/src/main/res/layout/item_search.xml new file mode 100644 index 0000000..85e3e13 --- /dev/null +++ b/app/src/main/res/layout/item_search.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + +