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 @@ <activity android:name=".audio_content.main.v2.series.origianl_audio_drama.OriginalAudioDramaContentAllActivity" /> <activity android:name=".audio_content.main.v2.series.completed.CompletedSeriesActivity" /> + <activity android:name=".search.SearchActivity" /> + <activity android:name=".mypage.alarm.AlarmListActivity" /> <activity android:name=".mypage.alarm.AddAlarmActivity" /> <activity android:name=".mypage.alarm.select_audio_content.AlarmSelectAudioContentActivity" /> 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<FragmentAudioContentMainTab if (SharedPreferenceManager.token.isNotBlank()) { binding.flSearch.visibility = View.VISIBLE binding.flSearch.setOnClickListener { + startActivity( + Intent( + requireContext(), + SearchActivity::class.java + ) + ) } } else { binding.flSearch.visibility = View.GONE 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 f713617..94cf9db 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 @@ -137,6 +137,9 @@ import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel import kr.co.vividnext.sodalive.network.TokenAuthenticator import kr.co.vividnext.sodalive.report.ReportApi import kr.co.vividnext.sodalive.report.ReportRepository +import kr.co.vividnext.sodalive.search.SearchApi +import kr.co.vividnext.sodalive.search.SearchRepository +import kr.co.vividnext.sodalive.search.SearchViewModel import kr.co.vividnext.sodalive.settings.ContentSettingsViewModel import kr.co.vividnext.sodalive.settings.SettingsViewModel import kr.co.vividnext.sodalive.settings.event.EventApi @@ -229,6 +232,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { single { ApiBuilder().build(get(), PlaylistApi::class.java) } single { ApiBuilder().build(get(), AuditionApi::class.java) } single { ApiBuilder().build(get(), AdTrackingApi::class.java) } + single { ApiBuilder().build(get(), SearchApi::class.java) } } private val viewModelModule = module { @@ -323,6 +327,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { IntroduceCreatorViewModel(get()) } viewModel { CompletedSeriesViewModel(get()) } viewModel { AlarmContentAllViewModel(get()) } + viewModel { SearchViewModel(get()) } } private val repositoryModule = module { @@ -363,6 +368,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { AudioContentMainTabFreeRepository(get()) } factory { OriginalAudioDramaContentAllRepository(get()) } factory { AdTrackingRepository(get()) } + factory { SearchRepository(get()) } } private val moduleList = listOf( diff --git a/app/src/main/java/kr/co/vividnext/sodalive/search/SearchActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchActivity.kt new file mode 100644 index 0000000..d6369b2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/search/SearchActivity.kt @@ -0,0 +1,556 @@ +package kr.co.vividnext.sodalive.search + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.tabs.TabLayout +import com.jakewharton.rxbinding4.widget.textChanges +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity +import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.base.BaseActivity +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.common.LoadingDialog +import kr.co.vividnext.sodalive.databinding.ActivitySearchBinding +import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity +import kr.co.vividnext.sodalive.extensions.dpToPx +import org.koin.android.ext.android.inject +import java.util.concurrent.TimeUnit + +class SearchActivity : BaseActivity<ActivitySearchBinding>(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<SearchAdapter.ViewHolder>() { + 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<SearchResponseItem> = 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<ApiResponse<SearchUnifiedResponse>> + + @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<ApiResponse<SearchResponse>> + + @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<ApiResponse<SearchResponse>> + + @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<ApiResponse<SearchResponse>> +} 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<SearchResponseItem>, + @SerializedName("contentList") val contentList: List<SearchResponseItem>, + @SerializedName("seriesList") val seriesList: List<SearchResponseItem> +) + +@Keep +data class SearchResponse( + @SerializedName("totalCount") val totalCount: Int, + @SerializedName("items") val items: List<SearchResponseItem> +) + +@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<SearchPageTab> + get() = _currentTabLiveData + + private val _toastLiveData = MutableLiveData<String?>() + val toastLiveData: LiveData<String?> + get() = _toastLiveData + + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData<Boolean> + get() = _isLoading + + private var _searchUnifiedLiveData = MutableLiveData<SearchUnifiedResponse>() + val searchUnifiedLiveData: LiveData<SearchUnifiedResponse> + get() = _searchUnifiedLiveData + + private var _searchCreatorLiveData = MutableLiveData<List<SearchResponseItem>>() + val searchCreatorLiveData: LiveData<List<SearchResponseItem>> + get() = _searchCreatorLiveData + + private var _searchContentLiveData = MutableLiveData<List<SearchResponseItem>>() + val searchContentLiveData: LiveData<List<SearchResponseItem>> + get() = _searchContentLiveData + + private var _searchSeriesLiveData = MutableLiveData<List<SearchResponseItem>>() + val searchSeriesLiveData: LiveData<List<SearchResponseItem>> + 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/black" + android:orientation="vertical"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="13.3dp" + android:gravity="center_vertical" + android:paddingTop="13.3dp"> + + <ImageView + android:id="@+id/iv_back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@null" + android:padding="13.3dp" + android:src="@drawable/ic_back" /> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="50dp" + android:background="@drawable/bg_round_corner_6_7_222222_bbbbbb"> + + <ImageView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:layout_gravity="center_vertical" + android:layout_marginStart="21.3dp" + android:contentDescription="@null" + android:src="@drawable/ic_title_search_black" /> + + <EditText + android:id="@+id/et_search" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center_vertical" + android:background="@null" + android:fontFamily="@font/gmarket_sans_medium" + android:gravity="center_vertical" + android:hint="검색" + android:importantForAutofill="no" + android:inputType="textWebEditText" + android:paddingHorizontal="54.67dp" + android:textColor="@color/color_eeeeee" + android:textColorHint="@color/color_555555" + android:textCursorDrawable="@drawable/edit_text_cursor" + android:textSize="13.3sp" /> + </RelativeLayout> + </LinearLayout> + + <com.google.android.material.tabs.TabLayout + android:id="@+id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="13.3dp" + android:background="@android:color/transparent" + android:clipToPadding="false" + android:elevation="0dp" + android:paddingHorizontal="13.3dp" + android:visibility="gone" + app:tabGravity="start" + app:tabIndicatorFullWidth="false" + app:tabIndicatorHeight="0dp" + app:tabMinWidth="45dp" + app:tabMode="scrollable" + app:tabPaddingBottom="15dp" + app:tabPaddingTop="15dp" + app:tabSelectedTextColor="@color/color_3bb9f1" + app:tabTextAppearance="@style/ContentMainTabText" + app:tabTextColor="@color/color_bbbbbb" /> + + <androidx.core.widget.NestedScrollView + android:id="@+id/ns_search_unified" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="gone"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <TextView + android:id="@+id/tv_creator_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:fontFamily="@font/gmarket_sans_bold" + android:text="채널" + android:textColor="@color/color_eeeeee" + android:textSize="16sp" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_unified_creator" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:padding="13.3dp" /> + + <TextView + android:id="@+id/tv_more_creator" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:layout_marginBottom="30dp" + android:background="@color/color_cc333333" + android:fontFamily="@font/gmarket_sans_medium" + android:gravity="center" + android:paddingVertical="10dp" + android:text="더보기 >" + android:textColor="@color/color_777777" + android:textSize="13.3sp" /> + + <TextView + android:id="@+id/tv_content_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:fontFamily="@font/gmarket_sans_bold" + android:text="콘텐츠" + android:textColor="@color/color_eeeeee" + android:textSize="16sp" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_unified_content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:padding="13.3dp" /> + + <TextView + android:id="@+id/tv_more_content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:layout_marginBottom="30dp" + android:background="@color/color_cc333333" + android:fontFamily="@font/gmarket_sans_medium" + android:gravity="center" + android:paddingVertical="10dp" + android:text="더보기 >" + android:textColor="@color/color_777777" + android:textSize="13.3sp" /> + + <TextView + android:id="@+id/tv_series_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:fontFamily="@font/gmarket_sans_bold" + android:text="시리즈" + android:textColor="@color/color_eeeeee" + android:textSize="16sp" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_unified_series" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:padding="13.3dp" /> + + <TextView + android:id="@+id/tv_more_series" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="13.3dp" + android:layout_marginBottom="13.3dp" + android:background="@color/color_cc333333" + android:fontFamily="@font/gmarket_sans_medium" + android:gravity="center" + android:paddingVertical="10dp" + android:text="더보기 >" + android:textColor="@color/color_777777" + android:textSize="13.3sp" /> + </LinearLayout> + </androidx.core.widget.NestedScrollView> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_creator" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:padding="13.3dp" + android:visibility="gone" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_content" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:padding="13.3dp" + android:visibility="gone" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_series" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:padding="13.3dp" + android:visibility="gone" /> + + <TextView + android:id="@+id/tv_result_x" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginVertical="40dp" + android:fontFamily="@font/gmarket_sans_medium" + android:gravity="center" + android:text="검색 결과가 없습니다." + android:textColor="@color/white" + android:textSize="18.3sp" + android:visibility="gone" /> +</LinearLayout> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/black" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <ImageView + android:id="@+id/iv_profile" + android:layout_width="60dp" + android:layout_height="60dp" + android:contentDescription="@null" + android:visibility="gone" /> + + <ImageView + android:id="@+id/iv_content" + android:layout_width="60dp" + android:layout_height="60dp" + android:contentDescription="@null" + android:visibility="gone" /> + + <ImageView + android:id="@+id/iv_series" + android:layout_width="60dp" + android:layout_height="85dp" + android:contentDescription="@null" + android:visibility="gone" /> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="13.3dp" + android:orientation="vertical"> + + <TextView + android:id="@+id/tv_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:fontFamily="@font/gmarket_sans_medium" + android:textColor="@color/color_eeeeee" + android:textSize="13.3sp" + tools:text="slefjeiwok" /> + + <TextView + android:id="@+id/tv_nickname" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="6.7dp" + android:fontFamily="@font/gmarket_sans_medium" + android:textColor="@color/color_777777" + android:textSize="10sp" + tools:ignore="SmallSp" + tools:text="slefjeiwok" /> + </LinearLayout> +</LinearLayout>