검색 UI 추가

This commit is contained in:
klaus 2025-03-27 06:04:24 +09:00
parent e4b0dbae82
commit c7af522cfb
12 changed files with 1335 additions and 2 deletions

View File

@ -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 {

View File

@ -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" />

View File

@ -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

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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>>
}

View File

@ -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
)
}

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -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>

View File

@ -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>