diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 199855442..f87f0f070 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,9 +74,6 @@ android { } } -// 룸 디비 제거 -// 좋아요 기능 좀더 생각해보기 - dependencies { // 프로젝트 의존성 implementation(projects.core.resource) @@ -131,7 +128,7 @@ dependencies { // UI 관련 유틸 라이브러리 implementation(libs.dots.indicator) // ViewPager2 indicator implementation(libs.lottie) // Lottie 애니메이션 - implementation(libs.pull.to.refresh) // Pull 새로고침 + implementation(libs.swipe.refresh.layout) // Pull 새로고침 // Third-party SDK implementation(libs.kakao) // 카카오 로그인 API diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f87ca51cd..a67f2cedf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,10 @@ android:name=".ui.novelRating.NovelRatingActivity" android:exported="false" android:screenOrientation="portrait" /> + - UserInterestFeedsEntity.UserInterestFeedEntity( - avatarImage = feed.avatarImage, - feedContent = feed.feedContent, - nickname = feed.nickname, - novelId = feed.novelId, - novelImage = feed.novelImage, - novelRating = feed.novelRating, - novelRatingCount = feed.novelRatingCount, - novelTitle = feed.novelTitle, - ) - }, - message = message, - ) diff --git a/app/src/main/java/com/into/websoso/data/model/UserInterestFeedsEntity.kt b/app/src/main/java/com/into/websoso/data/model/UserInterestFeedsEntity.kt deleted file mode 100644 index 7d08b5b24..000000000 --- a/app/src/main/java/com/into/websoso/data/model/UserInterestFeedsEntity.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.into.websoso.data.model - -data class UserInterestFeedsEntity( - val userInterestFeeds: List, - val message: String, -) { - data class UserInterestFeedEntity( - val avatarImage: String, - val feedContent: String, - val nickname: String, - val novelId: Int, - val novelImage: String, - val novelRating: Double, - val novelRatingCount: Int, - val novelTitle: String, - ) -} - -enum class UserInterestFeedMessage( - val message: String, -) { - NO_INTEREST_NOVELS("NO_INTEREST_NOVELS"), - NO_ASSOCIATED_FEEDS("NO_ASSOCIATED_FEEDS"), - ; - - companion object { - fun fromMessage(message: String): UserInterestFeedMessage? = entries.find { it.message == message } - } -} diff --git a/app/src/main/java/com/into/websoso/data/remote/api/FeedApi.kt b/app/src/main/java/com/into/websoso/data/remote/api/FeedApi.kt index 4c7a1f6eb..31986305c 100644 --- a/app/src/main/java/com/into/websoso/data/remote/api/FeedApi.kt +++ b/app/src/main/java/com/into/websoso/data/remote/api/FeedApi.kt @@ -5,7 +5,6 @@ import com.into.websoso.data.remote.response.CommentsResponseDto import com.into.websoso.data.remote.response.FeedDetailResponseDto import com.into.websoso.data.remote.response.FeedsResponseDto import com.into.websoso.data.remote.response.PopularFeedsResponseDto -import com.into.websoso.data.remote.response.UserInterestFeedsResponseDto import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE @@ -47,9 +46,6 @@ interface FeedApi { @GET("feeds/popular") suspend fun getPopularFeeds(): PopularFeedsResponseDto - @GET("feeds/interest") - suspend fun getUserInterestFeeds(): UserInterestFeedsResponseDto - @DELETE("feeds/{feedId}") suspend fun deleteFeed( @Path("feedId") feedId: Long, diff --git a/app/src/main/java/com/into/websoso/data/remote/response/UserInterestFeedsResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/UserInterestFeedsResponseDto.kt deleted file mode 100644 index af2615c75..000000000 --- a/app/src/main/java/com/into/websoso/data/remote/response/UserInterestFeedsResponseDto.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.into.websoso.data.remote.response - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UserInterestFeedsResponseDto( - @SerialName("recommendFeeds") - val userInterestFeeds: List, - @SerialName("message") - val message: String, -) { - @Serializable - data class UserInterestFeedResponseDto( - @SerialName("avatarImage") - val avatarImage: String, - @SerialName("feedContent") - val feedContent: String, - @SerialName("nickname") - val nickname: String, - @SerialName("novelId") - val novelId: Int, - @SerialName("novelImage") - val novelImage: String, - @SerialName("novelRating") - val novelRating: Double, - @SerialName("novelRatingCount") - val novelRatingCount: Int, - @SerialName("novelTitle") - val novelTitle: String, - ) -} diff --git a/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt b/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt index 54040827c..fefadad96 100644 --- a/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt @@ -1,11 +1,9 @@ package com.into.websoso.data.repository -import com.into.websoso.data.mapper.MultiPartMapper import com.into.websoso.data.mapper.toData import com.into.websoso.data.model.FeedEntity import com.into.websoso.data.model.FeedsEntity import com.into.websoso.data.model.PopularFeedsEntity -import com.into.websoso.data.model.UserInterestFeedsEntity import com.into.websoso.data.remote.api.FeedApi import javax.inject.Inject import javax.inject.Singleton @@ -37,8 +35,6 @@ class FeedRepository suspend fun fetchPopularFeeds(): PopularFeedsEntity = feedApi.getPopularFeeds().toData() - suspend fun fetchUserInterestFeeds(): UserInterestFeedsEntity = feedApi.getUserInterestFeeds().toData() - suspend fun saveRemovedFeed(feedId: Long) { runCatching { feedApi.deleteFeed(feedId) diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreActivity.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreActivity.kt new file mode 100644 index 000000000..1a55a3e19 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreActivity.kt @@ -0,0 +1,72 @@ +package com.into.websoso.ui.detailExplore + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.into.websoso.core.common.util.setupSystemBarIconColor +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.string.inquire_link +import com.into.websoso.ui.detailExploreResult.DetailExploreResultActivity +import com.into.websoso.ui.detailExploreResult.model.DetailExploreFilteredModel +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class DetailExploreActivity : AppCompatActivity() { + private val detailExploreViewModel: DetailExploreViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setupSystemBarIconColor(true) + + setContent { + WebsosoTheme { + DetailExploreScreen( + viewModel = detailExploreViewModel, + onBackClick = ::finish, + onSearchClick = ::navigateToSearchResult, + onKeywordInquireClick = ::navigateToKeywordInquire, + ) + } + } + } + + private fun navigateToSearchResult() { + val selectedGenres = detailExploreViewModel.selectedGenres.value ?: emptyList() + val isCompleted = detailExploreViewModel.selectedStatus.value?.isCompleted + val novelRating = detailExploreViewModel.selectedRating.value + + val keywordIds = detailExploreViewModel.uiState.value + ?.categories + ?.flatMap { it.keywords.asSequence() } + ?.filter { it.isSelected } + ?.map { it.keywordId } + ?.toList() ?: emptyList() + + startActivity( + DetailExploreResultActivity.getIntent( + context = this, + detailExploreFilteredModel = DetailExploreFilteredModel( + genres = selectedGenres, + isCompleted = isCompleted, + novelRating = novelRating, + keywordIds = keywordIds, + ), + ), + ) + } + + private fun navigateToKeywordInquire() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(inquire_link))) + startActivity(intent) + } + + companion object { + fun getIntent(context: Context): Intent = Intent(context, DetailExploreActivity::class.java) + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreDialogBottomSheet.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreDialogBottomSheet.kt deleted file mode 100644 index 3417030fc..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreDialogBottomSheet.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.into.websoso.ui.detailExplore - -import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.commit -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.into.websoso.R -import com.into.websoso.core.common.ui.base.BaseBottomSheetDialog -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.databinding.DialogDetailExploreBinding -import com.into.websoso.ui.detailExplore.info.DetailExploreInfoFragment -import com.into.websoso.ui.detailExplore.keyword.DetailExploreKeywordFragment -import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle -import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.INFO -import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.KEYWORD -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class DetailExploreDialogBottomSheet : BaseBottomSheetDialog(R.layout.dialog_detail_explore) { - private val detailExploreInfoFragment: DetailExploreInfoFragment by lazy { DetailExploreInfoFragment() } - private val detailExploreKeywordFragment: DetailExploreKeywordFragment by lazy { DetailExploreKeywordFragment() } - private val detailExploreViewModel: DetailExploreViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - setupBottomSheet() - initDetailExploreFragment() - onReplaceFragmentButtonClick() - onBottomSheetExitButtonClick() - setupObserver() - } - - private fun setupBottomSheet() { - (dialog as BottomSheetDialog).apply { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - behavior.isDraggable = false - behavior.isHideable = false - setCancelable(false) - } - } - - private fun initDetailExploreFragment() { - childFragmentManager.commit { - add( - R.id.fcv_detail_explore, - detailExploreInfoFragment, - ) - } - } - - private fun onReplaceFragmentButtonClick() { - binding.tvDetailExploreInfoButton.setOnClickListener { - singleEventHandler.throttleFirst { - switchFragment(INFO) - detailExploreViewModel.updateIsSearchKeywordProceeding(false) - } - } - - binding.tvDetailExploreKeywordButton.setOnClickListener { - singleEventHandler.throttleFirst { - switchFragment(KEYWORD) - } - } - } - - private fun switchFragment(selectedFragmentTitle: SelectedFragmentTitle) { - val fragmentToShow = when (selectedFragmentTitle) { - INFO -> detailExploreInfoFragment - KEYWORD -> { - if (childFragmentManager.findFragmentById(R.id.fcv_detail_explore) !is DetailExploreKeywordFragment) { - childFragmentManager.commit { - add(R.id.fcv_detail_explore, detailExploreKeywordFragment) - } - } - detailExploreKeywordFragment - } - } - - val fragmentToHide = when (selectedFragmentTitle) { - INFO -> detailExploreKeywordFragment - KEYWORD -> detailExploreInfoFragment - } - - childFragmentManager.commit { - setReorderingAllowed(true) - show(fragmentToShow) - hide(fragmentToHide) - } - - updateButtonColors(selectedFragmentTitle) - } - - private fun updateButtonColors(selectedFragmentTitle: SelectedFragmentTitle) { - val selectedColor = requireContext().getColor(R.color.primary_100_6A5DFD) - val defaultColor = requireContext().getColor(R.color.gray_200_949399) - - when (selectedFragmentTitle) { - INFO -> { - binding.apply { - tvDetailExploreInfoButton.setTextColor(selectedColor) - tvDetailExploreKeywordButton.setTextColor(defaultColor) - viewDetailExploreSelectedInfoTab.isVisible = true - viewDetailExploreSelectedKeywordTab.isVisible = false - } - } - - KEYWORD -> { - binding.apply { - tvDetailExploreKeywordButton.setTextColor(selectedColor) - tvDetailExploreInfoButton.setTextColor(defaultColor) - viewDetailExploreSelectedInfoTab.isVisible = false - viewDetailExploreSelectedKeywordTab.isVisible = true - } - } - } - } - - private fun onBottomSheetExitButtonClick() { - binding.ivDetailExploreExitButton.setOnClickListener { - detailExploreViewModel.updateSelectedInfoValueClear() - detailExploreViewModel.updateSelectedKeywordValueClear() - dismiss() - } - } - - private fun setupObserver() { - detailExploreViewModel.isInfoChipSelected.observe(viewLifecycleOwner) { isVisible -> - binding.ivDetailExploreInfoActiveDot.isVisible = isVisible - } - - detailExploreViewModel.isKeywordChipSelected.observe(viewLifecycleOwner) { isVisible -> - binding.ivDetailExploreKeywordActiveDot.isVisible = isVisible - } - } - - override fun onPause() { - detailExploreViewModel.updateSelectedInfoValueClear() - detailExploreViewModel.updateSelectedKeywordValueClear() - dismiss() - super.onPause() - } - - companion object { - fun newInstance(): DetailExploreDialogBottomSheet = DetailExploreDialogBottomSheet() - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreScreen.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreScreen.kt new file mode 100644 index 000000000..cc7e7172b --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreScreen.kt @@ -0,0 +1,76 @@ +package com.into.websoso.ui.detailExplore + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.ui.detailExplore.component.DetailExploreAppBar +import com.into.websoso.ui.detailExplore.component.DetailExploreCtaButton +import com.into.websoso.ui.detailExplore.component.DetailExploreInfoTab +import com.into.websoso.ui.detailExplore.component.DetailExploreKeywordTab +import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.INFO +import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.KEYWORD + +@Composable +fun DetailExploreScreen( + viewModel: DetailExploreViewModel, + onBackClick: () -> Unit, + onSearchClick: () -> Unit, + onKeywordInquireClick: () -> Unit, +) { + var selectedTab by rememberSaveable { mutableStateOf(INFO) } + + val isInfoChipSelected by viewModel.isInfoChipSelected.observeAsState(false) + val isKeywordChipSelected by viewModel.isKeywordChipSelected.observeAsState(false) + + BackHandler(onBack = onBackClick) + + Box( + modifier = Modifier + .fillMaxSize() + .background(White) + .windowInsetsPadding(WindowInsets.systemBars) + .padding(top = 10.dp), + ) { + Column(modifier = Modifier.fillMaxSize()) { + DetailExploreAppBar( + selectedTab = selectedTab, + isInfoChipActive = isInfoChipSelected, + isKeywordChipActive = isKeywordChipSelected, + onTabSelected = { selectedTab = it }, + onBackClick = onBackClick, + onResetClick = { + when (selectedTab) { + INFO -> viewModel.updateSelectedInfoValueClear() + KEYWORD -> viewModel.updateSelectedKeywordValueClear() + } + }, + ) + Box(modifier = Modifier.weight(1f)) { + when (selectedTab) { + INFO -> DetailExploreInfoTab(viewModel = viewModel) + + KEYWORD -> DetailExploreKeywordTab( + viewModel = viewModel, + onKeywordInquireClick = onKeywordInquireClick, + ) + } + } + DetailExploreCtaButton(onClick = onSearchClick) + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreViewModel.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreViewModel.kt index 66472eabe..cb2945b2e 100644 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/DetailExploreViewModel.kt @@ -29,10 +29,15 @@ class DetailExploreViewModel private val _selectedStatus: MutableLiveData = MutableLiveData() val selectedStatus: LiveData get() = _selectedStatus - private val _selectedRating: MutableLiveData = MutableLiveData() - val selectedRating: LiveData get() = _selectedRating + private val _selectedRatingMin: MutableLiveData = MutableLiveData(RATING_MIN) + val selectedRatingMin: LiveData get() = _selectedRatingMin - val ratings: List = listOf(3.5f, 4.0f, 4.5f, 4.8f) + private val _selectedRatingMax: MutableLiveData = MutableLiveData(RATING_MAX) + val selectedRatingMax: LiveData get() = _selectedRatingMax + + val selectedRating: LiveData = _selectedRatingMin.map { min -> + if (min > RATING_MIN) min else null + } private val _isInfoChipSelected: MediatorLiveData = MediatorLiveData(false) val isInfoChipSelected: LiveData get() = _isInfoChipSelected @@ -51,7 +56,10 @@ class DetailExploreViewModel _isInfoChipSelected.addSource(_selectedStatus) { _isInfoChipSelected.value = isInfoChipSelectedEnabled() } - _isInfoChipSelected.addSource(_selectedRating) { + _isInfoChipSelected.addSource(_selectedRatingMin) { + _isInfoChipSelected.value = isInfoChipSelectedEnabled() + } + _isInfoChipSelected.addSource(_selectedRatingMax) { _isInfoChipSelected.value = isInfoChipSelectedEnabled() } @@ -61,15 +69,18 @@ class DetailExploreViewModel private fun isInfoChipSelectedEnabled(): Boolean { val isGenreChipSelected: Boolean = _selectedGenres.value?.isNotEmpty() == true val isStatusChipSelected: Boolean = _selectedStatus.value != null - val isRatingChipSelected: Boolean = _selectedRating.value != null + val isRatingRangeNarrowed: Boolean = + (_selectedRatingMin.value ?: RATING_MIN) > RATING_MIN || + (_selectedRatingMax.value ?: RATING_MAX) < RATING_MAX - return isGenreChipSelected || isStatusChipSelected || isRatingChipSelected + return isGenreChipSelected || isStatusChipSelected || isRatingRangeNarrowed } fun updateSelectedInfoValueClear() { _selectedGenres.value = mutableListOf() _selectedStatus.value = null - _selectedRating.value = null + _selectedRatingMin.value = RATING_MIN + _selectedRatingMax.value = RATING_MAX } fun updateSelectedGenres(genre: Genre) { @@ -92,8 +103,12 @@ class DetailExploreViewModel _selectedStatus.value = status } - fun updateSelectedRating(rating: Float?) { - _selectedRating.value = rating + fun updateSelectedRatingRange( + min: Float, + max: Float, + ) { + _selectedRatingMin.value = min + _selectedRatingMax.value = max } fun updateKeyword(searchWord: String?) { @@ -188,4 +203,10 @@ class DetailExploreViewModel ) } } + + companion object { + const val RATING_MIN = 0.0f + const val RATING_MAX = 5.0f + const val RATING_STEP = 0.5f + } } diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreAppBar.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreAppBar.kt new file mode 100644 index 000000000..fd86ffaa4 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreAppBar.kt @@ -0,0 +1,154 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.util.clickableWithoutRipple +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.drawable.ic_detail_explore_reset +import com.into.websoso.core.resource.R.drawable.ic_navigate_left +import com.into.websoso.core.resource.R.string.detail_explore_info +import com.into.websoso.core.resource.R.string.detail_explore_keyword +import com.into.websoso.core.resource.R.string.detail_explore_reset +import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle +import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.INFO +import com.into.websoso.ui.detailExplore.model.SelectedFragmentTitle.KEYWORD + +@Composable +fun DetailExploreAppBar( + selectedTab: SelectedFragmentTitle, + isInfoChipActive: Boolean, + isKeywordChipActive: Boolean, + onTabSelected: (SelectedFragmentTitle) -> Unit, + onBackClick: () -> Unit, + onResetClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background(White) + .fillMaxWidth() + .height(44.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(44.dp) + .clickableWithoutRipple(onClick = onBackClick), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = ic_navigate_left), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + TabLabel( + label = stringResource(detail_explore_info), + isSelected = selectedTab == INFO, + isActive = isInfoChipActive, + slotWidth = 68.dp, + onClick = { onTabSelected(INFO) }, + ) + TabLabel( + label = stringResource(detail_explore_keyword), + isSelected = selectedTab == KEYWORD, + isActive = isKeywordChipActive, + slotWidth = 69.dp, + onClick = { onTabSelected(KEYWORD) }, + ) + Spacer(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .padding(horizontal = 16.dp) + .clickableWithoutRipple(onClick = onResetClick), + ) { + Image( + painter = painterResource(id = ic_detail_explore_reset), + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringResource(detail_explore_reset), + style = WebsosoTheme.typography.title2, + color = Gray300, + ) + } + } +} + +@Composable +private fun TabLabel( + label: String, + isSelected: Boolean, + isActive: Boolean, + slotWidth: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .width(slotWidth) + .height(30.dp) + .clickableWithoutRipple(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.Top) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(IntrinsicSize.Max), + ) { + Text( + text = label, + style = WebsosoTheme.typography.title1, + color = if (isSelected) Primary100 else Gray200, + ) + Spacer(modifier = Modifier.weight(1f)) + if (isSelected) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(Primary100), + ) + } + } + Spacer(modifier = Modifier.width(3.dp)) + Box( + modifier = Modifier + .size(4.dp) + .background( + color = if (isActive) Primary100 else Color.Transparent, + shape = CircleShape, + ), + ) + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreCtaButton.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreCtaButton.kt new file mode 100644 index 000000000..13cbba0ca --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreCtaButton.kt @@ -0,0 +1,52 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.util.SingleEventHandler +import com.into.websoso.core.common.util.clickableWithoutRipple +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.string.detail_explore_search_novel + +@Composable +fun DetailExploreCtaButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val singleEventHandler = remember { SingleEventHandler.from() } + Column( + modifier = modifier + .fillMaxWidth() + .background(White) + .padding(horizontal = 8.dp, vertical = 10.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Primary100) + .clickableWithoutRipple { singleEventHandler.throttleFirst(event = onClick) } + .padding(vertical = 18.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(detail_explore_search_novel), + style = WebsosoTheme.typography.title1, + color = White, + ) + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreInfoTab.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreInfoTab.kt new file mode 100644 index 000000000..a39ed8ad4 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreInfoTab.kt @@ -0,0 +1,239 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.string.detail_explore_info_genre +import com.into.websoso.core.resource.R.string.detail_explore_info_rating +import com.into.websoso.core.resource.R.string.detail_explore_info_rating_range +import com.into.websoso.core.resource.R.string.detail_explore_info_rating_value +import com.into.websoso.core.resource.R.string.detail_explore_info_status +import com.into.websoso.core.resource.R.string.detail_explore_info_status_complete +import com.into.websoso.core.resource.R.string.detail_explore_info_status_in_series +import com.into.websoso.ui.detailExplore.DetailExploreViewModel +import com.into.websoso.ui.detailExplore.DetailExploreViewModel.Companion.RATING_MAX +import com.into.websoso.ui.detailExplore.DetailExploreViewModel.Companion.RATING_MIN +import com.into.websoso.ui.detailExplore.DetailExploreViewModel.Companion.RATING_STEP +import com.into.websoso.ui.detailExplore.info.model.Genre +import com.into.websoso.ui.detailExplore.info.model.SeriesStatus + +@Composable +fun DetailExploreInfoTab( + viewModel: DetailExploreViewModel, + modifier: Modifier = Modifier, +) { + val selectedGenres by viewModel.selectedGenres.observeAsState(emptyList()) + val selectedStatus by viewModel.selectedStatus.observeAsState(null) + val ratingMin by viewModel.selectedRatingMin.observeAsState(RATING_MIN) + val ratingMax by viewModel.selectedRatingMax.observeAsState(RATING_MAX) + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + GenreSection( + selectedGenres = selectedGenres, + onGenreClick = viewModel::updateSelectedGenres, + ) + StatusSection( + selectedStatus = selectedStatus, + onStatusClick = { status -> + viewModel.updateSelectedSeriesStatus( + if (selectedStatus == status) null else status, + ) + }, + ) + RatingSection( + min = ratingMin, + max = ratingMax, + onRangeChange = viewModel::updateSelectedRatingRange, + ) + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun SectionTitle(text: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = text, + style = WebsosoTheme.typography.title2, + color = Black, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GenreSection( + selectedGenres: List, + onGenreClick: (Genre) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle(text = stringResource(detail_explore_info_genre)) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Genre.entries.forEach { genre -> + SelectableTagChip( + label = genre.titleKr, + isSelected = selectedGenres.contains(genre), + onClick = { onGenreClick(genre) }, + ) + } + } + } +} + +@Composable +private fun StatusSection( + selectedStatus: SeriesStatus?, + onStatusClick: (SeriesStatus) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle(text = stringResource(detail_explore_info_status)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + StatusChipCell( + label = stringResource(detail_explore_info_status_in_series), + status = SeriesStatus.IN_SERIES, + selectedStatus = selectedStatus, + onClick = onStatusClick, + modifier = Modifier.weight(1f), + ) + StatusChipCell( + label = stringResource(detail_explore_info_status_complete), + status = SeriesStatus.COMPLETED, + selectedStatus = selectedStatus, + onClick = onStatusClick, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun StatusChipCell( + label: String, + status: SeriesStatus, + selectedStatus: SeriesStatus?, + onClick: (SeriesStatus) -> Unit, + modifier: Modifier = Modifier, +) { + SelectableStatusChip( + label = label, + isSelected = selectedStatus == status, + onClick = { onClick(status) }, + modifier = modifier.wrapContentHeight(), + ) +} + +@Composable +private fun RatingSection( + min: Float, + max: Float, + onRangeChange: (Float, Float) -> Unit, +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(42.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(detail_explore_info_rating), + style = WebsosoTheme.typography.title2, + color = Black, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(detail_explore_info_rating_range, min, max), + style = WebsosoTheme.typography.body2, + color = Primary100, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RatingValueBox(value = min) + Box(modifier = Modifier.weight(1f)) { + RatingRangeSlider( + min = min, + max = max, + valueRange = RATING_MIN..RATING_MAX, + stepSize = RATING_STEP, + onValueChange = onRangeChange, + ) + } + RatingValueBox(value = max) + } + Spacer(modifier = Modifier.height(38.dp)) + } +} + +@Composable +private fun RatingValueBox(value: Float) { + Box( + modifier = Modifier + .size(width = 50.dp, height = 38.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Gray50), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(detail_explore_info_rating_value, value), + style = WebsosoTheme.typography.body2, + color = Primary100, + ) + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreKeywordTab.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreKeywordTab.kt new file mode 100644 index 000000000..1a5d7261a --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/DetailExploreKeywordTab.kt @@ -0,0 +1,431 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel +import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel.KeywordModel +import com.into.websoso.core.common.util.clickableWithoutRipple +import com.into.websoso.core.common.util.getS3ImageUrl +import com.into.websoso.core.designsystem.component.NetworkImage +import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray100 +import com.into.websoso.core.designsystem.theme.Gray200 +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.drawable.ic_common_search +import com.into.websoso.core.resource.R.drawable.ic_common_search_clear +import com.into.websoso.core.resource.R.drawable.ic_detail_explore_keyword_not_exist_result +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_keword_remove +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_keyword_toggle_selected +import com.into.websoso.core.resource.R.string.detail_explore_keyword_inquire_keyword +import com.into.websoso.core.resource.R.string.detail_explore_keyword_not_exist_result +import com.into.websoso.core.resource.R.string.detail_explore_keyword_search_result +import com.into.websoso.core.resource.R.string.detail_explore_search_hint +import com.into.websoso.ui.detailExplore.DetailExploreViewModel +import com.into.websoso.ui.detailExplore.keyword.model.DetailExploreKeywordUiState + +@Composable +fun DetailExploreKeywordTab( + viewModel: DetailExploreViewModel, + onKeywordInquireClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.observeAsState(DetailExploreKeywordUiState()) + var searchValue by remember { mutableStateOf(TextFieldValue("")) } + + val selectedKeywords = remember(uiState.categories) { + uiState.categories.flatMap { it.keywords }.filter { it.isSelected } + } + + Column(modifier = modifier.fillMaxWidth()) { + KeywordSearchField( + value = searchValue, + onValueChange = { value -> + searchValue = value + if (value.text.isEmpty()) { + viewModel.initSearchKeyword() + } else { + viewModel.updateIsSearchKeywordProceeding(true) + } + }, + onSearch = { + if (searchValue.text.isEmpty()) { + viewModel.initSearchKeyword() + } else { + viewModel.updateKeyword(searchValue.text) + } + }, + onClear = { + searchValue = TextFieldValue("") + viewModel.initSearchKeyword() + }, + ) + + if (selectedKeywords.isNotEmpty()) { + SelectedKeywordsRow( + keywords = selectedKeywords, + onRemove = viewModel::updateClickedChipState, + ) + } + + when { + uiState.isSearchKeywordProceeding && uiState.isSearchResultKeywordsEmpty -> { + KeywordEmptyResult(onInquireClick = onKeywordInquireClick) + } + + uiState.isSearchKeywordProceeding -> { + Divider() + SearchResultList( + keywords = uiState.searchResultKeywords, + isInitial = uiState.isInitialSearchKeyword, + onKeywordClick = viewModel::updateClickedChipState, + ) + } + + else -> { + CategoryList( + categories = uiState.categories, + onKeywordClick = viewModel::updateClickedChipState, + ) + } + } + } +} + +@Composable +private fun KeywordSearchField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + onSearch: () -> Unit, + onClear: () -> Unit, +) { + Box( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 12.dp) + .fillMaxWidth() + .height(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Gray50) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + cursorBrush = SolidColor(Primary100), + textStyle = WebsosoTheme.typography.body4.copy(color = Black), + ) + if (value.text.isEmpty()) { + Text( + text = stringResource(detail_explore_search_hint), + style = WebsosoTheme.typography.body4, + color = Gray200, + ) + } + } + if (value.text.isNotEmpty()) { + androidx.compose.foundation.Image( + painter = painterResource(id = ic_common_search_clear), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clickableWithoutRipple(onClick = onClear), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + androidx.compose.foundation.Image( + painter = painterResource(id = ic_common_search), + contentDescription = null, + modifier = Modifier + .size(20.dp) + .clickableWithoutRipple(onClick = onSearch), + ) + } + } +} + +@Composable +private fun SelectedKeywordsRow( + keywords: List, + onRemove: (Int) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + keywords.forEach { keyword -> + SelectedKeywordChip( + label = keyword.keywordName, + onRemove = { onRemove(keyword.keywordId) }, + ) + } + } +} + +@Composable +private fun SelectedKeywordChip( + label: String, + onRemove: () -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + Row( + modifier = Modifier + .clip(shape) + .border(width = 1.dp, color = Primary100, shape = shape) + .background(color = White, shape = shape) + .clickableWithoutRipple(onClick = onRemove) + .padding(start = 13.dp, end = 11.dp, top = 7.dp, bottom = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = label, + style = WebsosoTheme.typography.body2, + color = Primary100, + ) + androidx.compose.foundation.Image( + painter = painterResource(id = ic_novel_rating_keword_remove), + contentDescription = null, + modifier = Modifier.size(10.dp), + ) + } +} + +@Composable +private fun Divider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Gray50), + ) +} + +@Composable +private fun CategoryList( + categories: List, + onKeywordClick: (Int) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .background(Gray50), + contentPadding = PaddingValues(vertical = 12.dp, horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = categories, key = { it.categoryName }) { category -> + CategoryCard(category = category, onKeywordClick = onKeywordClick) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CategoryCard( + category: CategoryModel, + onKeywordClick: (Int) -> Unit, +) { + var isExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val imageUrl = remember(category.categoryImage) { + context.getS3ImageUrl(category.categoryImage) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(White), + ) { + Row( + modifier = Modifier + .padding(start = 20.dp, top = 20.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NetworkImage( + imageUrl = imageUrl, + contentDescription = null, + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Text( + text = category.categoryName, + style = WebsosoTheme.typography.title2, + color = Gray300, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .heightIn(max = if (isExpanded) Int.MAX_VALUE.dp else 92.dp) + .clip(RoundedCornerShape(0.dp)), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + category.keywords.forEach { keyword -> + Box(modifier = Modifier.padding(vertical = 4.dp)) { + SelectableTagChip( + label = keyword.keywordName, + isSelected = keyword.isSelected, + onClick = { onKeywordClick(keyword.keywordId) }, + ) + } + } + } + Spacer(modifier = Modifier.height(18.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Gray50), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .clickableWithoutRipple { isExpanded = !isExpanded }, + contentAlignment = Alignment.Center, + ) { + androidx.compose.foundation.Image( + painter = painterResource(id = ic_novel_rating_keyword_toggle_selected), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .rotate(if (isExpanded) 0f else 180f), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SearchResultList( + keywords: List, + isInitial: Boolean, + onKeywordClick: (Int) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + if (!isInitial) { + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = stringResource(detail_explore_keyword_search_result), + style = WebsosoTheme.typography.title3, + color = Gray300, + ) + Spacer(modifier = Modifier.height(20.dp)) + } + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + keywords.forEach { keyword -> + SelectableTagChip( + label = keyword.keywordName, + isSelected = keyword.isSelected, + onClick = { onKeywordClick(keyword.keywordId) }, + ) + } + } + } +} + +@Composable +private fun KeywordEmptyResult(onInquireClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + androidx.compose.foundation.Image( + painter = painterResource(id = ic_detail_explore_keyword_not_exist_result), + contentDescription = null, + modifier = Modifier.size(80.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(detail_explore_keyword_not_exist_result), + style = WebsosoTheme.typography.body1, + color = Gray200, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(36.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(14.dp)) + .background(Gray50) + .clickableWithoutRipple(onClick = onInquireClick) + .padding(horizontal = 26.dp, vertical = 20.dp), + ) { + Text( + text = stringResource(detail_explore_keyword_inquire_keyword), + style = WebsosoTheme.typography.title2, + color = Primary100, + ) + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/RatingRangeSlider.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/RatingRangeSlider.kt new file mode 100644 index 000000000..240115325 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/RatingRangeSlider.kt @@ -0,0 +1,163 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.Primary50 +import com.into.websoso.core.designsystem.theme.White +import kotlin.math.absoluteValue +import kotlin.math.round + +@Composable +fun RatingRangeSlider( + min: Float, + max: Float, + valueRange: ClosedFloatingPointRange, + stepSize: Float, + onValueChange: (Float, Float) -> Unit, + modifier: Modifier = Modifier, +) { + val totalRange = valueRange.endInclusive - valueRange.start + val tickCount = ((totalRange / stepSize).toInt() + 1).coerceAtLeast(2) + + val density = LocalDensity.current + val thumbRadiusPx = with(density) { 8.dp.toPx() } + + var sliderWidthPx by remember { mutableFloatStateOf(0f) } + var activeThumb by remember { mutableIntStateOf(0) } + + val trackWidthPx = (sliderWidthPx - thumbRadiusPx * 2).coerceAtLeast(0f) + val startFraction = ((min - valueRange.start) / totalRange).coerceIn(0f, 1f) + val endFraction = ((max - valueRange.start) / totalRange).coerceIn(0f, 1f) + val startCenterPx = thumbRadiusPx + trackWidthPx * startFraction + val endCenterPx = thumbRadiusPx + trackWidthPx * endFraction + + val latestMin by rememberUpdatedState(min) + val latestMax by rememberUpdatedState(max) + val latestStartCenter by rememberUpdatedState(startCenterPx) + val latestEndCenter by rememberUpdatedState(endCenterPx) + + fun snap(rawValue: Float): Float { + val stepped = round(rawValue / stepSize) * stepSize + return stepped.coerceIn(valueRange.start, valueRange.endInclusive) + } + + fun valueAtX(x: Float): Float { + if (trackWidthPx <= 0f) return valueRange.start + val fraction = ((x - thumbRadiusPx) / trackWidthPx).coerceIn(0f, 1f) + return snap(valueRange.start + fraction * totalRange) + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(16.dp) + .onSizeChanged { sliderWidthPx = it.width.toFloat() } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + val touchX = offset.x + activeThumb = + if ((touchX - latestStartCenter).absoluteValue <= + (touchX - latestEndCenter).absoluteValue + ) { + 1 + } else { + 2 + } + val newValue = valueAtX(touchX) + if (activeThumb == 1) { + onValueChange(newValue.coerceAtMost(latestMax), latestMax) + } else { + onValueChange(latestMin, newValue.coerceAtLeast(latestMin)) + } + }, + onDrag = { change, _ -> + change.consume() + val newValue = valueAtX(change.position.x) + if (activeThumb == 1) { + onValueChange(newValue.coerceAtMost(latestMax), latestMax) + } else { + onValueChange(latestMin, newValue.coerceAtLeast(latestMin)) + } + }, + ) + }, + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Primary50), + ) + Row( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth() + .height(4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(tickCount) { + Box( + modifier = Modifier + .size(width = 1.dp, height = 2.dp) + .background(Primary100, RoundedCornerShape(1.dp)), + ) + } + } + val activeStartDp = with(density) { startCenterPx.toDp() } + val activeWidthDp = with(density) { (endCenterPx - startCenterPx).coerceAtLeast(0f).toDp() } + Box( + modifier = Modifier + .offset(x = activeStartDp) + .width(activeWidthDp) + .height(4.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Primary100), + ) + ThumbCircle(offsetXDp = with(density) { (startCenterPx - thumbRadiusPx).toDp() }) + ThumbCircle(offsetXDp = with(density) { (endCenterPx - thumbRadiusPx).toDp() }) + } +} + +@Composable +private fun ThumbCircle(offsetXDp: androidx.compose.ui.unit.Dp) { + Box( + modifier = Modifier + .offset(x = offsetXDp) + .shadow(elevation = 2.dp, shape = CircleShape, clip = false) + .size(16.dp) + .background(White, CircleShape), + ) +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/component/SelectableChip.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/component/SelectableChip.kt new file mode 100644 index 000000000..6ba028449 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/detailExplore/component/SelectableChip.kt @@ -0,0 +1,90 @@ +package com.into.websoso.ui.detailExplore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.util.clickableWithoutRipple +import com.into.websoso.core.designsystem.theme.Gray300 +import com.into.websoso.core.designsystem.theme.Gray50 +import com.into.websoso.core.designsystem.theme.Primary100 +import com.into.websoso.core.designsystem.theme.Primary50 +import com.into.websoso.core.designsystem.theme.Transparent +import com.into.websoso.core.designsystem.theme.WebsosoTheme + +@Composable +fun SelectableTagChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + SelectableChipBase( + label = label, + isSelected = isSelected, + onClick = onClick, + cornerRadius = 20.dp, + horizontalPadding = 13.dp, + verticalPadding = 7.dp, + modifier = modifier, + ) +} + +@Composable +fun SelectableStatusChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + SelectableChipBase( + label = label, + isSelected = isSelected, + onClick = onClick, + cornerRadius = 8.dp, + horizontalPadding = 0.dp, + verticalPadding = 10.dp, + modifier = modifier, + ) +} + +@Composable +private fun SelectableChipBase( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + cornerRadius: Dp, + horizontalPadding: Dp, + verticalPadding: Dp, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(cornerRadius) + val background = if (isSelected) Primary50 else Gray50 + val borderColor = if (isSelected) Primary100 else Transparent + val textColor = if (isSelected) Primary100 else Gray300 + + Box( + modifier = modifier + .clip(shape) + .clickableWithoutRipple(onClick = onClick) + .background(color = background, shape = shape) + .border(width = 1.dp, color = borderColor, shape = shape) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = WebsosoTheme.typography.body2, + color = textColor, + ) + } +} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/info/DetailExploreInfoFragment.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/info/DetailExploreInfoFragment.kt deleted file mode 100644 index a2c8d886b..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/info/DetailExploreInfoFragment.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.into.websoso.ui.detailExplore.info - -import android.os.Bundle -import android.view.View -import androidx.core.view.forEach -import androidx.fragment.app.activityViewModels -import com.google.android.material.chip.Chip -import com.into.websoso.R -import com.into.websoso.core.common.ui.base.BaseFragment -import com.into.websoso.core.common.ui.custom.WebsosoChip -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.core.common.util.toFloatPxFromDp -import com.into.websoso.databinding.FragmentDetailExploreInfoBinding -import com.into.websoso.ui.detailExplore.DetailExploreViewModel -import com.into.websoso.ui.detailExplore.info.model.Genre -import com.into.websoso.ui.detailExplore.info.model.Rating -import com.into.websoso.ui.detailExplore.info.model.SeriesStatus -import com.into.websoso.ui.detailExploreResult.DetailExploreResultActivity -import com.into.websoso.ui.detailExploreResult.model.DetailExploreFilteredModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class DetailExploreInfoFragment : BaseFragment(R.layout.fragment_detail_explore_info) { - private val detailExploreViewModel: DetailExploreViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - bindViewModel() - onResetButtonClick() - onDetailSearchNovelButtonClick() - setupGenreChips() - setupSeriesStatusChips() - setupRatingChips() - setupObserver() - } - - private fun bindViewModel() { - binding.detailExploreViewModel = detailExploreViewModel - binding.lifecycleOwner = this - } - - private fun onResetButtonClick() { - binding.clDetailExploreInfoResetButton.setOnClickListener { - detailExploreViewModel.updateSelectedInfoValueClear() - } - } - - private fun onDetailSearchNovelButtonClick() { - binding.tvDetailExploreSearchButton.setOnClickListener { - singleEventHandler.throttleFirst { - val selectedGenres = detailExploreViewModel.selectedGenres.value ?: emptyList() - val isCompleted = detailExploreViewModel.selectedStatus.value?.isCompleted - val novelRating = detailExploreViewModel.selectedRating.value - - val keywordIds = detailExploreViewModel.uiState.value - ?.categories - ?.flatMap { it.keywords.asSequence() } - ?.filter { it.isSelected } - ?.map { it.keywordId } - ?.toList() ?: emptyList() - - val intent = DetailExploreResultActivity.getIntent( - context = requireContext(), - DetailExploreFilteredModel( - genres = selectedGenres, - isCompleted = isCompleted, - novelRating = novelRating, - keywordIds = keywordIds, - ), - ) - - startActivity(intent) - } - } - } - - private fun setupGenreChips() { - val genres = Genre.entries - genres.forEach { genre -> - WebsosoChip(requireContext()) - .apply { - setWebsosoChipText(genre.titleKr) - setWebsosoChipTextAppearance(R.style.body2) - setWebsosoChipTextColor(R.color.bg_detail_explore_chip_text_selector) - setWebsosoChipStrokeColor(R.color.bg_detail_explore_chip_stroke_selector) - setWebsosoChipBackgroundColor(R.color.bg_detail_explore_chip_background_selector) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnWebsosoChipClick { detailExploreViewModel.updateSelectedGenres(genre) } - }.also { websosoChip -> binding.wcgDetailExploreInfoGenre.addChip(websosoChip) } - } - } - - private fun setupSeriesStatusChips() { - val seriesStatusChips = listOf( - binding.chipDetailExploreInfoStatusInSeries, - binding.chipDetailExploreInfoStatusComplete, - ) - - seriesStatusChips.forEach { chip -> - setupChipCheckListener(chip) { isChecked -> - when (isChecked) { - true -> { - seriesStatusChips.filter { it != chip }.forEach { it.isChecked = false } - - val status = SeriesStatus.from(chip.text.toString()) - detailExploreViewModel.updateSelectedSeriesStatus(status) - } - - false -> { - detailExploreViewModel.updateSelectedSeriesStatus(null) - } - } - } - } - } - - private fun setupRatingChips() { - val ratingChips = listOf( - binding.chipDetailExploreInfoRatingLowest, - binding.chipDetailExploreInfoRatingLower, - binding.chipDetailExploreInfoRatingHigher, - binding.chipDetailExploreInfoRatingHighest, - ) - - ratingChips.forEach { chip -> - setupChipCheckListener(chip) { isChecked -> - when (isChecked) { - true -> { - ratingChips.filter { it != chip }.forEach { it.isChecked = false } - val ratingValue = - Rating.entries.find { - chip.text.toString().contains(it.value.toString()) - } - detailExploreViewModel.updateSelectedRating(ratingValue?.value) - } - - false -> detailExploreViewModel.updateSelectedRating(null) - } - } - } - } - - private fun setupChipCheckListener( - chip: Chip, - onCheckedChange: (Boolean) -> Unit, - ) { - chip.setOnCheckedChangeListener(null) - chip.isChecked = false - chip.setOnCheckedChangeListener { _, isChecked -> onCheckedChange(isChecked) } - } - - private fun setupObserver() { - detailExploreViewModel.selectedGenres.observe(viewLifecycleOwner) { genres -> - if (genres.isNullOrEmpty()) { - binding.wcgDetailExploreInfoGenre.forEach { - it.isSelected = false - } - } - } - - detailExploreViewModel.selectedStatus.observe(viewLifecycleOwner) { selectedStatus -> - val selectedChip = selectedStatus?.title - - listOf( - binding.chipDetailExploreInfoStatusInSeries, - binding.chipDetailExploreInfoStatusComplete, - ).forEach { chip -> - chip.isChecked = chip.text.toString() == selectedChip - } - } - - detailExploreViewModel.selectedRating.observe(viewLifecycleOwner) { selectedRating -> - listOf( - binding.chipDetailExploreInfoRatingLowest, - binding.chipDetailExploreInfoRatingLower, - binding.chipDetailExploreInfoRatingHigher, - binding.chipDetailExploreInfoRatingHighest, - ).forEach { chip -> - val ratingValue = - Rating.entries.find { chip.text.toString().contains(it.value.toString()) } - chip.isChecked = selectedRating == ratingValue?.value - } - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/info/model/Rating.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/info/model/Rating.kt deleted file mode 100644 index b31c640df..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/info/model/Rating.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.into.websoso.ui.detailExplore.info.model - -enum class Rating( - val value: Float, -) { - LOWEST(3.5f), - LOWER(4.0f), - HIGHER(4.5f), - HIGHEST(4.8f), -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreClickListener.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreClickListener.kt deleted file mode 100644 index 2fe604822..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreClickListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.into.websoso.ui.detailExplore.keyword - -interface DetailExploreClickListener { - fun onNovelInquireButtonClick() - - fun onDetailSearchNovelButtonClick() - - fun onKeywordResetButtonClick() -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreKeywordFragment.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreKeywordFragment.kt deleted file mode 100644 index b91c62b0a..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/DetailExploreKeywordFragment.kt +++ /dev/null @@ -1,322 +0,0 @@ -package com.into.websoso.ui.detailExplore.keyword - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.core.view.children -import androidx.core.view.forEach -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import com.into.websoso.R.color.bg_novel_rating_chip_background_selector -import com.into.websoso.R.color.bg_novel_rating_chip_stroke_selector -import com.into.websoso.R.color.bg_novel_rating_chip_text_selector -import com.into.websoso.R.color.primary_100_6A5DFD -import com.into.websoso.R.color.white -import com.into.websoso.R.layout.fragment_detail_explore_keyword -import com.into.websoso.R.style.body2 -import com.into.websoso.core.common.ui.base.BaseFragment -import com.into.websoso.core.common.ui.custom.WebsosoChip -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel.KeywordModel -import com.into.websoso.core.common.ui.model.CategoriesModel.Companion.findKeywordByName -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.core.common.util.toFloatPxFromDp -import com.into.websoso.core.common.util.tracker.Tracker -import com.into.websoso.core.resource.R.drawable.ic_novel_rating_keword_remove -import com.into.websoso.core.resource.R.string.detail_explore_search_hint -import com.into.websoso.core.resource.R.string.inquire_link -import com.into.websoso.databinding.FragmentDetailExploreKeywordBinding -import com.into.websoso.ui.detailExplore.DetailExploreViewModel -import com.into.websoso.ui.detailExplore.keyword.adapter.DetailExploreKeywordAdapter -import com.into.websoso.ui.detailExplore.keyword.model.DetailExploreKeywordUiState -import com.into.websoso.ui.detailExploreResult.DetailExploreResultActivity -import com.into.websoso.ui.detailExploreResult.model.DetailExploreFilteredModel -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class DetailExploreKeywordFragment : BaseFragment(fragment_detail_explore_keyword) { - @Inject - lateinit var tracker: Tracker - - private val detailExploreViewModel: DetailExploreViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - private val detailExploreKeywordAdapter: DetailExploreKeywordAdapter by lazy { - DetailExploreKeywordAdapter(detailExploreViewModel::updateClickedChipState) - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - bindViewModel() - setupAdapter() - setupObserver() - setupSearchKeyword() - setupWebsosoSearchEditListener() - setupBackButtonListener() - } - - private fun bindViewModel() { - binding.detailExploreViewModel = detailExploreViewModel - binding.onClick = onDetailExploreKeywordButtonClick() - binding.lifecycleOwner = viewLifecycleOwner - } - - private fun onDetailExploreKeywordButtonClick() = - object : DetailExploreClickListener { - override fun onNovelInquireButtonClick() { - tracker.trackEvent("contact_keyword") - val inquireUrl = getString(inquire_link) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(inquireUrl)) - startActivity(intent) - } - - override fun onDetailSearchNovelButtonClick() { - singleEventHandler.throttleFirst { - navigateToDetailSearchResult() - } - } - - override fun onKeywordResetButtonClick() { - detailExploreViewModel.updateSelectedKeywordValueClear() - } - } - - private fun navigateToDetailSearchResult() { - val selectedGenres = detailExploreViewModel.selectedGenres.value ?: emptyList() - val isCompleted = detailExploreViewModel.selectedStatus.value?.isCompleted - val novelRating = detailExploreViewModel.selectedRating.value - - val keywordIds = detailExploreViewModel.uiState.value - ?.categories - ?.asSequence() - ?.flatMap { it.keywords.asSequence() } - ?.filter { it.isSelected } - ?.map { it.keywordId } - ?.toList() ?: emptyList() - - val intent = DetailExploreResultActivity.getIntent( - context = requireContext(), - DetailExploreFilteredModel( - genres = selectedGenres, - isCompleted = isCompleted, - novelRating = novelRating, - keywordIds = keywordIds, - ), - ) - - startActivity(intent) - } - - private fun setupAdapter() { - binding.rvDetailExploreKeywordList.apply { - adapter = detailExploreKeywordAdapter - itemAnimator = null - } - } - - private fun setupObserver() { - detailExploreViewModel.uiState.observe(viewLifecycleOwner) { uiState -> - detailExploreKeywordAdapter.submitList(uiState.categories) - setupSelectedScrollViewVisibility(uiState.categories) - setupSelectedChips(uiState.categories) - updateSearchKeywordResult(uiState) - } - } - - private fun setupSelectedScrollViewVisibility(categories: List) { - val hasSelectedKeywords = categories.flatMap { it.keywords }.any { it.isSelected } - - binding.hsvRatingKeywordSelectedKeyword.isVisible = hasSelectedKeywords - } - - private fun setupSelectedChips(categories: List) { - val currentChipKeywords = - binding.wcgDetailExploreKeywordSelectedKeyword.children - .filterIsInstance() - .map { it.text.toString() } - .toList() - - val selectedKeywords = - categories - .asSequence() - .flatMap { it.keywords.asSequence() } - .filter { it.isSelected } - .map { it.keywordName } - .toList() - - val chipsToRemove = currentChipKeywords - selectedKeywords.toSet() - val chipsToAdd = selectedKeywords - currentChipKeywords.toSet() - - chipsToRemove.forEach { keywordName -> - removeSelectedChip(keywordName) - } - - chipsToAdd.forEach { keywordName -> - createSelectedChip( - categories.findKeywordByName(keywordName) - ?: throw IllegalArgumentException("Keyword not found: $keywordName"), - ) - } - } - - private fun removeSelectedChip(keywordName: String) { - val chipToRemove = binding.wcgDetailExploreKeywordSelectedKeyword.children.find { - (it as WebsosoChip).text == keywordName - } - chipToRemove?.let { - binding.wcgDetailExploreKeywordSelectedKeyword.removeView(it) - } - } - - private fun createSelectedChip(selectedKeyword: KeywordModel) { - WebsosoChip(requireContext()) - .apply { - setWebsosoChipText(selectedKeyword.keywordName) - setWebsosoChipTextAppearance(body2) - setWebsosoChipTextColor(primary_100_6A5DFD) - setWebsosoChipStrokeColor(primary_100_6A5DFD) - setWebsosoChipBackgroundColor(white) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnCloseIconClickListener { - detailExploreViewModel.updateClickedChipState( - selectedKeyword.keywordId, - ) - } - setWebsosoChipCloseIconVisibility(true) - setWebsosoChipCloseIconDrawable(ic_novel_rating_keword_remove) - setWebsosoChipCloseIconSize(10f.toFloatPxFromDp()) - setWebsosoChipCloseIconEndPadding(12f.toFloatPxFromDp()) - setCloseIconTintResource(primary_100_6A5DFD) - }.also { websosoChip -> - binding.wcgDetailExploreKeywordSelectedKeyword.addChip(websosoChip) - } - } - - private fun updateSearchKeywordResult(uiState: DetailExploreKeywordUiState) { - val previousSearchResultKeywords = - binding.wcgDetailExploreKeywordResult.children - .toList() - .map { it as WebsosoChip } - - when { - !uiState.isSearchKeywordProceeding -> return - - uiState.isSearchResultKeywordsEmpty -> return - - uiState.searchResultKeywords.map { it.keywordName } == previousSearchResultKeywords.map { it.text.toString() } -> { - updateSearchKeywordResultIsSelected(uiState) - return - } - - else -> { - binding.wcgDetailExploreKeywordResult.removeAllViews() - updateSearchKeywordResultWebsosoChips(uiState) - } - } - } - - private fun updateSearchKeywordResultIsSelected(uiState: DetailExploreKeywordUiState) { - binding.wcgDetailExploreKeywordResult.forEach { view -> - val chip = view as? WebsosoChip ?: return@forEach - - val isSelected = - uiState.categories - .asSequence() - .flatMap { it.keywords } - .filter { it.isSelected } - .any { it.keywordName == chip.text.toString() } - - chip.isSelected = isSelected - } - } - - private fun updateSearchKeywordResultWebsosoChips(uiState: DetailExploreKeywordUiState) { - uiState.searchResultKeywords.forEach { keyword -> - val isSelected = uiState.categories - .asSequence() - .flatMap { it.keywords } - .any { it.keywordId == keyword.keywordId && it.isSelected } - - WebsosoChip(binding.root.context) - .apply { - setWebsosoChipText(keyword.keywordName) - setWebsosoChipTextAppearance(body2) - setWebsosoChipTextColor(bg_novel_rating_chip_text_selector) - setWebsosoChipStrokeColor(bg_novel_rating_chip_stroke_selector) - setWebsosoChipBackgroundColor(bg_novel_rating_chip_background_selector) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnWebsosoChipClick { - detailExploreViewModel.updateClickedChipState(keyword.keywordId) - } - this.isSelected = isSelected - }.also { websosoChip -> - binding.wcgDetailExploreKeywordResult.addChip(websosoChip) - } - } - } - - private fun setupSearchKeyword() { - binding.wsetDetailExploreKeywordSearch.apply { - setWebsosoSearchHint(getString(detail_explore_search_hint)) - } - } - - private fun setupWebsosoSearchEditListener() { - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchActionListener { _, _, _ -> - performSearch() - true - } - - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchFocusChangeListener { _, isFocused -> - if (isFocused) detailExploreViewModel.updateIsSearchKeywordProceeding(true) - } - - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchClearClickListener { - initSearchKeyword() - } - } - - private fun performSearch() { - val input = binding.wsetDetailExploreKeywordSearch.getWebsosoSearchText() - if (input.isEmpty()) { - initSearchKeyword() - return - } - detailExploreViewModel.updateKeyword(input) - } - - private fun initSearchKeyword() { - binding.wsetDetailExploreKeywordSearch.clearWebsosoSearchFocus() - detailExploreViewModel.initSearchKeyword() - } - - private fun setupBackButtonListener() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.wsetDetailExploreKeywordSearch.hasFocus()) { - initSearchKeyword() - } else { - requireActivity().onBackPressedDispatcher.onBackPressed() - } - } - }, - ) - } - - override fun onDestroyView() { - initSearchKeyword() - super.onDestroyView() - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordAdapter.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordAdapter.kt deleted file mode 100644 index 750f35066..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.into.websoso.ui.detailExplore.keyword.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel -import com.into.websoso.databinding.ItemCommonKeywordBinding - -class DetailExploreKeywordAdapter( - private val onKeywordClick: (keywordId: Int) -> (Unit), -) : ListAdapter(diffUtil) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): DetailExploreKeywordViewHolder { - val binding = ItemCommonKeywordBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return DetailExploreKeywordViewHolder(binding, onKeywordClick) - } - - override fun onBindViewHolder( - holder: DetailExploreKeywordViewHolder, - position: Int, - ) { - val item = getItem(position) - holder.apply { - when (isChipSetting) { - true -> updateChipState(item) - false -> initKeywordView(item) - } - } - } - - companion object { - private val diffUtil = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: CategoryModel, - newItem: CategoryModel, - ): Boolean = oldItem.categoryName == newItem.categoryName - - override fun areContentsTheSame( - oldItem: CategoryModel, - newItem: CategoryModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordViewHolder.kt b/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordViewHolder.kt deleted file mode 100644 index bb52b5dcb..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExplore/keyword/adapter/DetailExploreKeywordViewHolder.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.into.websoso.ui.detailExplore.keyword.adapter - -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.recyclerview.widget.RecyclerView -import com.into.websoso.R -import com.into.websoso.core.common.ui.custom.WebsosoChip -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel -import com.into.websoso.core.common.util.getS3ImageUrl -import com.into.websoso.core.common.util.toFloatPxFromDp -import com.into.websoso.core.common.util.toIntPxFromDp -import com.into.websoso.databinding.ItemCommonKeywordBinding - -class DetailExploreKeywordViewHolder( - private val binding: ItemCommonKeywordBinding, - private val onKeywordClick: (keywordId: Int) -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - var isChipSetting: Boolean = false - private set - - init { - binding.setupExpandToggleBtn() - } - - private fun ItemCommonKeywordBinding.setupExpandToggleBtn() { - ivNovelRatingKeywordToggle.setOnClickListener { - ivNovelRatingKeywordToggle.isSelected = !ivNovelRatingKeywordToggle.isSelected - val layoutParams = - wcgNovelRatingKeyword.layoutParams as ConstraintLayout.LayoutParams - - when (ivNovelRatingKeywordToggle.isSelected) { - true -> - layoutParams.matchConstraintMaxHeight = - ConstraintLayout.LayoutParams.WRAP_CONTENT - - false -> layoutParams.matchConstraintMaxHeight = 92.toIntPxFromDp() - } - wcgNovelRatingKeyword.layoutParams = layoutParams - } - } - - fun initKeywordView(category: CategoryModel) { - val imageUrl: String = itemView.getS3ImageUrl(category.categoryImage) - - val updatedCategory = category.copy( - categoryImage = imageUrl, - ) - - binding.apply { - tvRatingKeyword.text = updatedCategory.categoryName - categoryImageUrl = updatedCategory.categoryImage - setupWebsosoChips(category) - } - isChipSetting = true - } - - private fun ItemCommonKeywordBinding.setupWebsosoChips(category: CategoryModel) { - wcgNovelRatingKeyword.removeAllViews() - category.keywords.forEach { keyword -> - WebsosoChip(binding.root.context) - .apply { - setWebsosoChipText(keyword.keywordName) - setWebsosoChipTextAppearance(R.style.body2) - setWebsosoChipTextColor(R.color.bg_novel_rating_chip_text_selector) - setWebsosoChipStrokeColor(R.color.bg_novel_rating_chip_stroke_selector) - setWebsosoChipBackgroundColor(R.color.bg_novel_rating_chip_background_selector) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnWebsosoChipClick { - onKeywordClick(keyword.keywordId) - } - isSelected = keyword.isSelected - }.also { websosoChip -> wcgNovelRatingKeyword.addChip(websosoChip) } - } - } - - fun updateChipState(category: CategoryModel) { - val keywordSelectionMap = - category.keywords.associateBy({ it.keywordName }, { it.isSelected }) - - (0 until binding.wcgNovelRatingKeyword.childCount) - .map { binding.wcgNovelRatingKeyword.getChildAt(it) } - .filterIsInstance() - .forEach { chip -> - chip.isSelected = keywordSelectionMap[chip.text] ?: false - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultActivity.kt b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultActivity.kt index be6eeaf25..bf306b9d8 100644 --- a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultActivity.kt @@ -145,16 +145,7 @@ class DetailExploreResultActivity : BaseActivity(R.layout.dialog_detail_explore) { - private val detailExploreResultInfoFragment: DetailExploreResultInfoFragment by lazy { DetailExploreResultInfoFragment() } - private val detailExploreResultKeywordFragment: DetailExploreResultKeywordFragment by lazy { DetailExploreResultKeywordFragment() } - private val detailExploreResultViewModel: DetailExploreResultViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - setupBottomSheet() - initDetailExploreFragment() - onReplaceFragmentButtonClick() - onBottomSheetExitButtonClick() - setupObserver() - } - - private fun setupBottomSheet() { - (dialog as BottomSheetDialog).apply { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - behavior.isDraggable = false - behavior.isHideable = false - setCancelable(false) - } - } - - private fun initDetailExploreFragment() { - childFragmentManager.commit { - add( - R.id.fcv_detail_explore, - detailExploreResultInfoFragment, - ) - } - } - - private fun onReplaceFragmentButtonClick() { - binding.tvDetailExploreInfoButton.setOnClickListener { - singleEventHandler.throttleFirst { - switchFragment(INFO) - } - } - - binding.tvDetailExploreKeywordButton.setOnClickListener { - singleEventHandler.throttleFirst { - switchFragment(KEYWORD) - } - } - } - - private fun switchFragment(selectedFragmentTitle: SelectedFragmentTitle) { - val fragmentToShow = when (selectedFragmentTitle) { - INFO -> detailExploreResultInfoFragment - KEYWORD -> { - if (childFragmentManager.findFragmentById(R.id.fcv_detail_explore) !is DetailExploreResultKeywordFragment) { - childFragmentManager.commit { - add(R.id.fcv_detail_explore, detailExploreResultKeywordFragment) - } - } - detailExploreResultKeywordFragment - } - } - - val fragmentToHide = when (selectedFragmentTitle) { - INFO -> detailExploreResultKeywordFragment - KEYWORD -> detailExploreResultInfoFragment - } - - childFragmentManager.commit { - setReorderingAllowed(true) - show(fragmentToShow) - hide(fragmentToHide) - } - - updateButtonColors(selectedFragmentTitle) - } - - private fun updateButtonColors(selectedFragmentTitle: SelectedFragmentTitle) { - val selectedColor = requireContext().getColor(R.color.primary_100_6A5DFD) - val defaultColor = requireContext().getColor(R.color.gray_200_949399) - - when (selectedFragmentTitle) { - INFO -> { - binding.apply { - tvDetailExploreInfoButton.setTextColor(selectedColor) - tvDetailExploreKeywordButton.setTextColor(defaultColor) - viewDetailExploreSelectedInfoTab.isVisible = true - viewDetailExploreSelectedKeywordTab.isVisible = false - } - } - - KEYWORD -> { - binding.apply { - tvDetailExploreKeywordButton.setTextColor(selectedColor) - tvDetailExploreInfoButton.setTextColor(defaultColor) - viewDetailExploreSelectedInfoTab.isVisible = false - viewDetailExploreSelectedKeywordTab.isVisible = true - } - } - } - } - - private fun onBottomSheetExitButtonClick() { - binding.ivDetailExploreExitButton.setOnClickListener { - dismiss() - } - } - - private fun setupObserver() { - detailExploreResultViewModel.isInfoChipSelected.observe(viewLifecycleOwner) { isVisible -> - binding.ivDetailExploreInfoActiveDot.isVisible = isVisible - } - - detailExploreResultViewModel.isKeywordChipSelected.observe(viewLifecycleOwner) { isVisible -> - binding.ivDetailExploreKeywordActiveDot.isVisible = isVisible - } - } - - companion object { - fun newInstance(): DetailExploreResultDialogBottomSheet = DetailExploreResultDialogBottomSheet() - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultInfoFragment.kt b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultInfoFragment.kt deleted file mode 100644 index 6d6b3ea33..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultInfoFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.into.websoso.ui.detailExploreResult - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import com.google.android.material.chip.Chip -import com.into.websoso.R -import com.into.websoso.core.common.ui.base.BaseFragment -import com.into.websoso.core.common.ui.custom.WebsosoChip -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.core.common.util.toFloatPxFromDp -import com.into.websoso.databinding.FragmentDetailExploreResultInfoBinding -import com.into.websoso.ui.detailExplore.info.model.Genre -import com.into.websoso.ui.detailExplore.info.model.Rating -import com.into.websoso.ui.detailExplore.info.model.SeriesStatus -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class DetailExploreResultInfoFragment : - BaseFragment(R.layout.fragment_detail_explore_result_info) { - private val detailExploreResultViewModel: DetailExploreResultViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - bindViewModel() - onResetButtonClick() - onDetailSearchNovelButtonClick() - setupGenreChips() - setupSeriesStatusChips() - setupRatingChips() - setupObserver() - } - - private fun bindViewModel() { - binding.detailExploreResultViewModel = detailExploreResultViewModel - binding.lifecycleOwner = this - } - - private fun onResetButtonClick() { - binding.clDetailExploreInfoResetButton.setOnClickListener { - detailExploreResultViewModel.updateSelectedInfoValueClear() - } - } - - private fun onDetailSearchNovelButtonClick() { - binding.tvDetailExploreSearchButton.setOnClickListener { - singleEventHandler.throttleFirst { - detailExploreResultViewModel.updateSearchResult(isSearchButtonClick = true) - - (parentFragment as? DetailExploreResultDialogBottomSheet)?.dismiss() - - detailExploreResultViewModel.updateIsBottomSheetOpen(false) - } - } - } - - private fun setupGenreChips() { - val genres = Genre.entries - genres.forEach { genre -> - WebsosoChip(requireContext()) - .apply { - setWebsosoChipText(genre.titleKr) - setWebsosoChipTextAppearance(R.style.body2) - setWebsosoChipTextColor(R.color.bg_detail_explore_chip_text_selector) - setWebsosoChipStrokeColor(R.color.bg_detail_explore_chip_stroke_selector) - setWebsosoChipBackgroundColor(R.color.bg_detail_explore_chip_background_selector) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnWebsosoChipClick { detailExploreResultViewModel.updateSelectedGenres(genre) } - }.also { websosoChip -> binding.wcgDetailExploreInfoGenre.addChip(websosoChip) } - } - } - - private fun setupSeriesStatusChips() { - val seriesStatusChips = listOf( - binding.chipDetailExploreInfoStatusInSeries, - binding.chipDetailExploreInfoStatusComplete, - ) - - seriesStatusChips.forEach { chip -> - setupChipCheckListener(chip) { isChecked -> - when (isChecked) { - true -> { - seriesStatusChips.filter { it != chip }.forEach { it.isChecked = false } - val status = SeriesStatus.from(chip.text.toString()) - detailExploreResultViewModel.updateSelectedSeriesStatus(status) - } - - false -> { - detailExploreResultViewModel.updateSelectedSeriesStatus(null) - } - } - } - } - } - - private fun setupRatingChips() { - val ratingChips = listOf( - binding.chipDetailExploreInfoRatingLowest, - binding.chipDetailExploreInfoRatingLower, - binding.chipDetailExploreInfoRatingHigher, - binding.chipDetailExploreInfoRatingHighest, - ) - - ratingChips.forEach { chip -> - setupChipCheckListener(chip) { isChecked -> - when (isChecked) { - true -> { - ratingChips.filter { it != chip }.forEach { it.isChecked = false } - val ratingValue = - Rating.entries.find { - chip.text.toString().contains(it.value.toString()) - } - detailExploreResultViewModel.updateSelectedRating(ratingValue?.value) - } - - false -> detailExploreResultViewModel.updateSelectedRating(null) - } - } - } - } - - private fun setupChipCheckListener( - chip: Chip, - onCheckedChange: (Boolean) -> Unit, - ) { - chip.setOnCheckedChangeListener(null) - chip.setOnCheckedChangeListener { _, isChecked -> onCheckedChange(isChecked) } - } - - private fun setupObserver() { - detailExploreResultViewModel.selectedGenres.observe(viewLifecycleOwner) { genres -> - (0 until binding.wcgDetailExploreInfoGenre.childCount) - .map { binding.wcgDetailExploreInfoGenre.getChildAt(it) } - .filterIsInstance() - .forEach { chip -> - chip.isSelected = genres?.any { it.titleKr == chip.text } == true - } - } - - detailExploreResultViewModel.isNovelCompleted.observe(viewLifecycleOwner) { selectedStatus -> - val selectedChip = selectedStatus?.let { - SeriesStatus.fromIsCompleted(it).title - } - - listOf( - binding.chipDetailExploreInfoStatusInSeries, - binding.chipDetailExploreInfoStatusComplete, - ).forEach { chip -> - chip.isChecked = chip.text.toString() == selectedChip - } - } - - detailExploreResultViewModel.selectedRating.observe(viewLifecycleOwner) { selectedRating -> - listOf( - binding.chipDetailExploreInfoRatingLowest, - binding.chipDetailExploreInfoRatingLower, - binding.chipDetailExploreInfoRatingHigher, - binding.chipDetailExploreInfoRatingHighest, - ).forEach { chip -> - val ratingValue = - Rating.entries.find { chip.text.toString().contains(it.value.toString()) } - chip.isChecked = selectedRating == ratingValue?.value - } - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultKeywordFragment.kt b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultKeywordFragment.kt deleted file mode 100644 index 3399ff386..000000000 --- a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultKeywordFragment.kt +++ /dev/null @@ -1,299 +0,0 @@ -package com.into.websoso.ui.detailExploreResult - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.core.view.children -import androidx.core.view.forEach -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import com.into.websoso.R -import com.into.websoso.R.color.bg_novel_rating_chip_background_selector -import com.into.websoso.R.color.bg_novel_rating_chip_stroke_selector -import com.into.websoso.R.color.bg_novel_rating_chip_text_selector -import com.into.websoso.R.color.primary_100_6A5DFD -import com.into.websoso.R.color.white -import com.into.websoso.R.style.body2 -import com.into.websoso.core.common.ui.base.BaseFragment -import com.into.websoso.core.common.ui.custom.WebsosoChip -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel.KeywordModel -import com.into.websoso.core.common.ui.model.CategoriesModel.Companion.findKeywordByName -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.core.common.util.toFloatPxFromDp -import com.into.websoso.core.common.util.tracker.Tracker -import com.into.websoso.core.resource.R.drawable.ic_novel_rating_keword_remove -import com.into.websoso.core.resource.R.string.detail_explore_search_hint -import com.into.websoso.core.resource.R.string.inquire_link -import com.into.websoso.databinding.FragmentDetailExploreResultKeywordBinding -import com.into.websoso.ui.detailExplore.keyword.DetailExploreClickListener -import com.into.websoso.ui.detailExplore.keyword.adapter.DetailExploreKeywordAdapter -import com.into.websoso.ui.detailExploreResult.model.DetailExploreResultUiState -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class DetailExploreResultKeywordFragment : - BaseFragment(R.layout.fragment_detail_explore_result_keyword) { - @Inject - lateinit var tracker: Tracker - - private val detailExploreResultViewModel: DetailExploreResultViewModel by activityViewModels() - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - private val detailExploreKeywordAdapter: DetailExploreKeywordAdapter by lazy { - DetailExploreKeywordAdapter(detailExploreResultViewModel::updateClickedChipState) - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - bindViewModel() - setupAdapter() - setupObserver() - setupSearchKeyword() - setupWebsosoSearchEditListener() - setupBackButtonListener() - tracker.trackEvent("seek_result") - } - - private fun bindViewModel() { - binding.detailExploreResultViewModel = detailExploreResultViewModel - binding.onClick = onDetailExploreKeywordButtonClick() - binding.lifecycleOwner = viewLifecycleOwner - } - - private fun onDetailExploreKeywordButtonClick() = - object : DetailExploreClickListener { - override fun onNovelInquireButtonClick() { - val inquireUrl = getString(inquire_link) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(inquireUrl)) - startActivity(intent) - } - - override fun onDetailSearchNovelButtonClick() { - singleEventHandler.throttleFirst { - detailExploreResultViewModel.updateSearchResult(true) - - (parentFragment as? DetailExploreResultDialogBottomSheet)?.dismiss() - - detailExploreResultViewModel.updateIsBottomSheetOpen(false) - } - } - - override fun onKeywordResetButtonClick() { - detailExploreResultViewModel.updateSelectedKeywordValueClear() - } - } - - private fun setupAdapter() { - binding.rvDetailExploreKeywordList.apply { - adapter = detailExploreKeywordAdapter - itemAnimator = null - } - } - - private fun setupObserver() { - detailExploreResultViewModel.uiState.observe(viewLifecycleOwner) { uiState -> - detailExploreKeywordAdapter.submitList(uiState.categories) - setupSelectedScrollViewVisibility(uiState.categories) - setupSelectedChips(uiState.categories) - updateSearchKeywordResult(uiState) - } - } - - private fun setupSelectedScrollViewVisibility(categories: List) { - val hasSelectedKeywords = categories.flatMap { it.keywords }.any { it.isSelected } - - binding.hsvRatingKeywordSelectedKeyword.isVisible = hasSelectedKeywords - } - - private fun setupSelectedChips(categories: List) { - val currentChipKeywords = - binding.wcgDetailExploreKeywordSelectedKeyword.children - .filterIsInstance() - .map { it.text.toString() } - .toList() - - val selectedKeywords = - categories - .asSequence() - .flatMap { it.keywords.asSequence() } - .filter { it.isSelected } - .map { it.keywordName } - .toList() - - val chipsToRemove = currentChipKeywords - selectedKeywords.toSet() - val chipsToAdd = selectedKeywords - currentChipKeywords.toSet() - - chipsToRemove.forEach { keywordName -> - removeSelectedChip(keywordName) - } - - chipsToAdd.forEach { keywordName -> - createSelectedChip( - categories.findKeywordByName(keywordName) - ?: throw IllegalArgumentException("Keyword not found: $keywordName"), - ) - } - } - - private fun removeSelectedChip(keywordName: String) { - val chipToRemove = binding.wcgDetailExploreKeywordSelectedKeyword.children.find { - (it as WebsosoChip).text == keywordName - } - chipToRemove?.let { - binding.wcgDetailExploreKeywordSelectedKeyword.removeView(it) - } - } - - private fun createSelectedChip(selectedKeyword: KeywordModel) { - WebsosoChip(requireContext()) - .apply { - setWebsosoChipText(selectedKeyword.keywordName) - setWebsosoChipTextAppearance(body2) - setWebsosoChipTextColor(primary_100_6A5DFD) - setWebsosoChipStrokeColor(primary_100_6A5DFD) - setWebsosoChipBackgroundColor(white) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(4f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnCloseIconClickListener { - detailExploreResultViewModel.updateClickedChipState( - selectedKeyword.keywordId, - ) - } - setWebsosoChipCloseIconVisibility(true) - setWebsosoChipCloseIconDrawable(ic_novel_rating_keword_remove) - setWebsosoChipCloseIconSize(20f) - setWebsosoChipCloseIconEndPadding(18f) - setCloseIconTintResource(primary_100_6A5DFD) - }.also { websosoChip -> - binding.wcgDetailExploreKeywordSelectedKeyword.addChip(websosoChip) - } - } - - private fun updateSearchKeywordResult(uiState: DetailExploreResultUiState) { - val previousSearchResultKeywords = - binding.wcgDetailExploreKeywordResult.children - .toList() - .map { it as WebsosoChip } - - when { - !uiState.isSearchKeywordProceeding -> return - - uiState.isSearchResultKeywordsEmpty -> return - - uiState.searchResultKeywords.map { it.keywordName } == previousSearchResultKeywords.map { it.text.toString() } -> { - updateSearchKeywordResultIsSelected(uiState) - return - } - - else -> { - binding.wcgDetailExploreKeywordResult.removeAllViews() - updateSearchKeywordResultWebsosoChips(uiState) - } - } - } - - private fun updateSearchKeywordResultIsSelected(uiState: DetailExploreResultUiState) { - binding.wcgDetailExploreKeywordResult.forEach { view -> - val chip = view as? WebsosoChip ?: return@forEach - - val isSelected = - uiState.categories - .asSequence() - .flatMap { it.keywords } - .filter { it.isSelected } - .any { it.keywordName == chip.text.toString() } - - chip.isSelected = isSelected - } - } - - private fun updateSearchKeywordResultWebsosoChips(uiState: DetailExploreResultUiState) { - uiState.searchResultKeywords.forEach { keyword -> - val isSelected = uiState.categories - .asSequence() - .flatMap { it.keywords } - .any { it.keywordId == keyword.keywordId && it.isSelected } - - WebsosoChip(binding.root.context) - .apply { - setWebsosoChipText(keyword.keywordName) - setWebsosoChipTextAppearance(body2) - setWebsosoChipTextColor(bg_novel_rating_chip_text_selector) - setWebsosoChipStrokeColor(bg_novel_rating_chip_stroke_selector) - setWebsosoChipBackgroundColor(bg_novel_rating_chip_background_selector) - setWebsosoChipPaddingVertical(12f.toFloatPxFromDp()) - setWebsosoChipPaddingHorizontal(6f.toFloatPxFromDp()) - setWebsosoChipRadius(20f.toFloatPxFromDp()) - setOnWebsosoChipClick { - detailExploreResultViewModel.updateClickedChipState(keyword.keywordId) - } - this.isSelected = isSelected - }.also { websosoChip -> - binding.wcgDetailExploreKeywordResult.addChip(websosoChip) - } - } - } - - private fun setupSearchKeyword() { - binding.wsetDetailExploreKeywordSearch.apply { - setWebsosoSearchHint(getString(detail_explore_search_hint)) - } - } - - private fun setupWebsosoSearchEditListener() { - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchActionListener { _, _, _ -> - performSearch() - true - } - - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchFocusChangeListener { _, isFocused -> - if (isFocused) detailExploreResultViewModel.updateIsSearchKeywordProceeding(true) - } - - binding.wsetDetailExploreKeywordSearch.setOnWebsosoSearchClearClickListener { - initSearchKeyword() - } - } - - private fun performSearch() { - val input = binding.wsetDetailExploreKeywordSearch.getWebsosoSearchText() - if (input.isEmpty()) { - initSearchKeyword() - return - } - detailExploreResultViewModel.updateKeyword(input) - } - - private fun initSearchKeyword() { - binding.wsetDetailExploreKeywordSearch.clearWebsosoSearchFocus() - detailExploreResultViewModel.initSearchKeyword() - } - - private fun setupBackButtonListener() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.wsetDetailExploreKeywordSearch.hasFocus()) { - initSearchKeyword() - } else { - requireActivity().onBackPressedDispatcher.onBackPressed() - } - } - }, - ) - } - - override fun onDestroyView() { - initSearchKeyword() - super.onDestroyView() - } -} diff --git a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultViewModel.kt b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultViewModel.kt index d25edb937..f8ae543a9 100644 --- a/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/detailExploreResult/DetailExploreResultViewModel.kt @@ -4,13 +4,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope -import com.into.websoso.core.common.ui.model.CategoriesModel -import com.into.websoso.data.repository.KeywordRepository import com.into.websoso.domain.usecase.GetDetailExploreResultUseCase import com.into.websoso.ui.detailExplore.info.model.Genre -import com.into.websoso.ui.detailExplore.info.model.SeriesStatus import com.into.websoso.ui.detailExploreResult.model.DetailExploreFilteredModel import com.into.websoso.ui.detailExploreResult.model.DetailExploreResultUiState import com.into.websoso.ui.mapper.toUi @@ -23,127 +19,49 @@ class DetailExploreResultViewModel @Inject constructor( private val getDetailExploreResultUseCase: GetDetailExploreResultUseCase, - private val keywordRepository: KeywordRepository, ) : ViewModel() { private val _uiState: MutableLiveData = MutableLiveData(DetailExploreResultUiState()) val uiState: LiveData get() = _uiState - private val _selectedGenres: MutableLiveData?> = - MutableLiveData(mutableListOf()) - val selectedGenres: LiveData?> get() = _selectedGenres.map { it?.toList() } - - private val _isNovelCompleted: MutableLiveData = MutableLiveData() - val isNovelCompleted: LiveData get() = _isNovelCompleted - - private val _selectedSeriesStatus: MutableLiveData = MutableLiveData() - - private val _selectedRating: MutableLiveData = MutableLiveData() - val selectedRating: LiveData get() = _selectedRating - - val ratings: List = listOf(3.5f, 4.0f, 4.5f, 4.8f) - - private val _selectedKeywordIds: MutableLiveData?> = - MutableLiveData(mutableListOf()) - val selectedKeywordIds: LiveData?> get() = _selectedKeywordIds.map { it?.toList() } + private val filterGenres: MutableLiveData?> = MutableLiveData(emptyList()) + private val filterIsNovelCompleted: MutableLiveData = MutableLiveData() + private val filterRating: MutableLiveData = MutableLiveData() + private val filterKeywordIds: MutableLiveData?> = MutableLiveData(emptyList()) private val _appliedFiltersMessage: MediatorLiveData = MediatorLiveData() val appliedFiltersMessage: LiveData get() = _appliedFiltersMessage - private val _isInfoChipSelected: MediatorLiveData = MediatorLiveData(false) - val isInfoChipSelected: LiveData get() = _isInfoChipSelected - - private val _isKeywordChipSelected: MediatorLiveData = MediatorLiveData(false) - val isKeywordChipSelected: LiveData get() = _isKeywordChipSelected - private val _isNovelResultEmptyBoxVisibility: MutableLiveData = MutableLiveData(false) val isNovelResultEmptyBoxVisibility: LiveData get() = _isNovelResultEmptyBoxVisibility - private val isBottomSheetOpen = MutableLiveData(false) - init { _appliedFiltersMessage.apply { - addSource(_selectedGenres) { updateMessage() } - addSource(_isNovelCompleted) { updateMessage() } - addSource(_selectedRating) { updateMessage() } - addSource(_selectedKeywordIds) { updateMessage() } - } - - _isInfoChipSelected.apply { - addSource(_selectedGenres) { isInfoChipSelectedEnabled() } - addSource(_isNovelCompleted) { isInfoChipSelectedEnabled() } - addSource(_selectedRating) { isInfoChipSelectedEnabled() } + addSource(filterGenres) { updateMessage() } + addSource(filterIsNovelCompleted) { updateMessage() } + addSource(filterRating) { updateMessage() } + addSource(filterKeywordIds) { updateMessage() } } - - _isKeywordChipSelected.addSource(_selectedKeywordIds) { isKeywordChipSelectedEnabled() } - updateKeyword(null) } private fun updateMessage() { - if (isBottomSheetOpen.value == true) return - val appliedFilters = mutableListOf() - _selectedGenres.value?.let { genres -> - if (genres.isNotEmpty()) { - appliedFilters.add(GENRES_LABEL) - } - } - - _isNovelCompleted.value?.let { - appliedFilters.add(NOVEL_COMPLETED_LABEL) - } - - _selectedRating.value?.let { - appliedFilters.add(RATING_LABEL) - } - - _selectedKeywordIds.value?.let { keywords -> - if (keywords.isNotEmpty()) { - appliedFilters.add(KEYWORDS_LABEL) - } - } + if (filterGenres.value?.isNotEmpty() == true) appliedFilters.add(GENRES_LABEL) + if (filterIsNovelCompleted.value != null) appliedFilters.add(NOVEL_COMPLETED_LABEL) + if (filterRating.value != null) appliedFilters.add(RATING_LABEL) + if (filterKeywordIds.value?.isNotEmpty() == true) appliedFilters.add(KEYWORDS_LABEL) _appliedFiltersMessage.value = appliedFilters.joinToString(FILTER_SEPARATOR) } - private fun isInfoChipSelectedEnabled() { - val isGenreChipSelected: Boolean = _selectedGenres.value?.isNotEmpty() == true - val isStatusChipSelected: Boolean = _isNovelCompleted.value != null - val isRatingChipSelected: Boolean = _selectedRating.value != null - - _isInfoChipSelected.value = - isGenreChipSelected || - isStatusChipSelected || - isRatingChipSelected - } - - private fun isKeywordChipSelectedEnabled() { - _isKeywordChipSelected.value = selectedKeywordIds.value?.isNotEmpty() - } - fun updatePreviousSearchFilteredValue(detailExploreFilteredModel: DetailExploreFilteredModel) { - _selectedGenres.value = detailExploreFilteredModel.genres?.toMutableList() - _isNovelCompleted.value = detailExploreFilteredModel.isCompleted - _selectedRating.value = detailExploreFilteredModel.novelRating - _selectedKeywordIds.value = detailExploreFilteredModel.keywordIds?.toMutableList() - - val currentUiState = _uiState.value ?: return - val selectedKeywordIds = _selectedKeywordIds.value.orEmpty() - - val updatedCategories = currentUiState.categories.map { category -> - val updatedKeywords = category.keywords.map { existingKeyword -> - if (selectedKeywordIds.contains(existingKeyword.keywordId)) { - existingKeyword.copy(isSelected = !existingKeyword.isSelected) - } else { - existingKeyword - } - } - category.copy(keywords = updatedKeywords) - } + filterGenres.value = detailExploreFilteredModel.genres + filterIsNovelCompleted.value = detailExploreFilteredModel.isCompleted + filterRating.value = detailExploreFilteredModel.novelRating + filterKeywordIds.value = detailExploreFilteredModel.keywordIds updateSearchResult(true) - _uiState.value = currentUiState.copy(categories = updatedCategories) } fun updateSearchResult(isSearchButtonClick: Boolean) { @@ -152,10 +70,10 @@ class DetailExploreResultViewModel viewModelScope.launch { runCatching { getDetailExploreResultUseCase( - genres = selectedGenres.value?.map { it.titleEn }, - isCompleted = isNovelCompleted.value, - novelRating = selectedRating.value, - keywordIds = selectedKeywordIds.value, + genres = filterGenres.value?.map { it.titleEn }, + isCompleted = filterIsNovelCompleted.value, + novelRating = filterRating.value, + keywordIds = filterKeywordIds.value, isSearchButtonClick = isSearchButtonClick, ) }.onSuccess { results -> @@ -190,153 +108,6 @@ class DetailExploreResultViewModel } } - fun updateSelectedInfoValueClear() { - _selectedGenres.value = mutableListOf() - _isNovelCompleted.value = null - _selectedRating.value = null - } - - fun updateSelectedGenres(genre: Genre) { - val currentGenres = _selectedGenres.value?.toMutableList() ?: mutableListOf() - - _selectedGenres.value = when (currentGenres.contains(genre)) { - true -> { - currentGenres.remove(genre) - currentGenres - } - - false -> { - currentGenres.add(genre) - currentGenres - } - } - } - - fun updateSelectedSeriesStatus(status: SeriesStatus?) { - _selectedSeriesStatus.value = status - _isNovelCompleted.value = status?.isCompleted - } - - fun updateSelectedRating(rating: Float?) { - _selectedRating.value = rating - } - - fun updateKeyword(searchWord: String?) { - viewModelScope.launch { - runCatching { - keywordRepository.fetchKeywords(searchWord) - }.onSuccess { keywordsList -> - val categoriesModel = - CategoriesModel(categories = keywordsList.categories.map { it.toUi() }) - - val selectedKeywordIds = selectedKeywordIds.value.orEmpty() - - val updatedCategories = categoriesModel.categories.map { category -> - val updatedKeywords = category.keywords.map { keyword -> - if (selectedKeywordIds.contains(keyword.keywordId)) { - keyword.copy(isSelected = true) - } else { - keyword - } - } - category.copy(keywords = updatedKeywords) - } - - _uiState.value = when (searchWord) { - null -> { - uiState.value?.copy( - loading = false, - categories = updatedCategories, - ) - } - - else -> { - val results = updatedCategories.flatMap { it.keywords } - uiState.value?.copy( - loading = false, - searchResultKeywords = results, - isInitialSearchKeyword = false, - isSearchResultKeywordsEmpty = results.isEmpty(), - ) - } - } - }.onFailure { - _uiState.value = uiState.value?.copy( - loading = false, - error = true, - ) - } - } - } - - fun updateClickedChipState(keywordId: Int) { - val currentUiState = _uiState.value ?: return - - val updatedCategories = currentUiState.categories.map { category -> - val updatedKeywords = category.keywords.map { existingKeyword -> - when (existingKeyword.keywordId == keywordId) { - true -> existingKeyword.copy(isSelected = !existingKeyword.isSelected) - false -> existingKeyword - } - } - category.copy(keywords = updatedKeywords) - } - - val selectedKeywordIds = updatedCategories.flatMap { category -> - category.keywords.filter { it.isSelected }.map { it.keywordId } - } - - val isAnyKeywordSelected = updatedCategories.any { category -> - category.keywords.any { it.isSelected } - } - - _selectedKeywordIds.value = selectedKeywordIds.toMutableList() - _isKeywordChipSelected.value = isAnyKeywordSelected - _uiState.value = currentUiState.copy(categories = updatedCategories) - } - - fun updateSelectedKeywordValueClear() { - val currentState = _uiState.value ?: return - val updatedCategories = currentState.categories - - val resetCategories = updatedCategories.map { category -> - category.copy( - keywords = category.keywords.map { keyword -> - keyword.copy(isSelected = false) - }, - ) - } - - _selectedKeywordIds.value = mutableListOf() - _uiState.value = currentState.copy(categories = resetCategories) - _isKeywordChipSelected.value = false - } - - fun updateIsSearchKeywordProceeding(isProceeding: Boolean) { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - isSearchKeywordProceeding = isProceeding, - ) - } - } - - fun initSearchKeyword() { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - searchResultKeywords = emptyList(), - isSearchKeywordProceeding = false, - isInitialSearchKeyword = true, - isSearchResultKeywordsEmpty = false, - ) - } - } - - fun updateIsBottomSheetOpen(isBottomSheetOpen: Boolean) { - this.isBottomSheetOpen.value = isBottomSheetOpen - - if (!isBottomSheetOpen) updateMessage() - } - companion object { private const val GENRES_LABEL = "장르" private const val NOVEL_COMPLETED_LABEL = "연재상태" diff --git a/app/src/main/java/com/into/websoso/ui/detailExploreResult/model/DetailExploreResultUiState.kt b/app/src/main/java/com/into/websoso/ui/detailExploreResult/model/DetailExploreResultUiState.kt index 0be8226de..a5cba5f8d 100644 --- a/app/src/main/java/com/into/websoso/ui/detailExploreResult/model/DetailExploreResultUiState.kt +++ b/app/src/main/java/com/into/websoso/ui/detailExploreResult/model/DetailExploreResultUiState.kt @@ -1,6 +1,5 @@ package com.into.websoso.ui.detailExploreResult.model -import com.into.websoso.core.common.ui.model.CategoriesModel.CategoryModel import com.into.websoso.ui.normalExplore.model.NormalExploreModel data class DetailExploreResultUiState( @@ -9,9 +8,4 @@ data class DetailExploreResultUiState( val isLoadable: Boolean = false, val novels: List = emptyList(), val novelCount: Long = 0, - val categories: List = emptyList(), - val searchResultKeywords: List = emptyList(), - val isSearchKeywordProceeding: Boolean = false, - val isInitialSearchKeyword: Boolean = true, - val isSearchResultKeywordsEmpty: Boolean = false, ) diff --git a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt index 36ce24f74..2c6fad011 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt @@ -12,7 +12,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.firebase.messaging.FirebaseMessaging import com.into.websoso.R.id.fcv_main -import com.into.websoso.R.id.menu_explore import com.into.websoso.R.id.menu_feed import com.into.websoso.R.id.menu_home import com.into.websoso.R.id.menu_library @@ -24,12 +23,10 @@ import com.into.websoso.core.resource.R.drawable.ic_blocked_user_snack_bar import com.into.websoso.core.resource.R.string.main_back_press import com.into.websoso.databinding.ActivityMainBinding import com.into.websoso.ui.common.dialog.LoginRequestDialogFragment -import com.into.websoso.ui.main.MainActivity.FragmentType.EXPLORE import com.into.websoso.ui.main.MainActivity.FragmentType.FEED import com.into.websoso.ui.main.MainActivity.FragmentType.HOME import com.into.websoso.ui.main.MainActivity.FragmentType.LIBRARY import com.into.websoso.ui.main.MainActivity.FragmentType.MY_PAGE -import com.into.websoso.ui.main.explore.ExploreFragment import com.into.websoso.ui.main.feed.FeedFragment import com.into.websoso.ui.main.home.HomeFragment import com.into.websoso.ui.main.library.LibraryFragment @@ -44,7 +41,6 @@ class MainActivity : BaseActivity(activity_main) { private val fragmentTags = mapOf( menu_home to HomeFragment.TAG, - menu_explore to ExploreFragment.TAG, menu_feed to FeedFragment.TAG, menu_library to LibraryFragment.TAG, menu_my_page to MyPageFragment.TAG, @@ -168,7 +164,6 @@ class MainActivity : BaseActivity(activity_main) { private fun findOrCreateFragment(tag: String): Fragment = supportFragmentManager.findFragmentByTag(tag) ?: when (tag) { HomeFragment.TAG -> HomeFragment() - ExploreFragment.TAG -> ExploreFragment() FeedFragment.TAG -> FeedFragment() LibraryFragment.TAG -> LibraryFragment() MyPageFragment.TAG -> MyPageFragment() @@ -203,7 +198,6 @@ class MainActivity : BaseActivity(activity_main) { private fun handleNavigation(destination: FragmentType?) { val menuId = when (destination) { - EXPLORE -> menu_explore MY_PAGE -> menu_my_page FEED -> menu_feed LIBRARY -> menu_library @@ -257,7 +251,6 @@ class MainActivity : BaseActivity(activity_main) { ) { LIBRARY(menu_library), HOME(menu_home), - EXPLORE(menu_explore), FEED(menu_feed), MY_PAGE(menu_my_page), ; diff --git a/app/src/main/java/com/into/websoso/ui/main/explore/ExploreFragment.kt b/app/src/main/java/com/into/websoso/ui/main/explore/ExploreFragment.kt deleted file mode 100644 index 83359a1c4..000000000 --- a/app/src/main/java/com/into/websoso/ui/main/explore/ExploreFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.into.websoso.ui.main.explore - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import com.into.websoso.R -import com.into.websoso.core.common.ui.base.BaseFragment -import com.into.websoso.core.common.util.SingleEventHandler -import com.into.websoso.core.common.util.tracker.Tracker -import com.into.websoso.databinding.FragmentExploreBinding -import com.into.websoso.ui.detailExplore.DetailExploreDialogBottomSheet -import com.into.websoso.ui.main.explore.adapter.SosoPickAdapter -import com.into.websoso.ui.normalExplore.NormalExploreActivity -import com.into.websoso.ui.novelDetail.NovelDetailActivity -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class ExploreFragment : BaseFragment(R.layout.fragment_explore) { - @Inject - lateinit var tracker: Tracker - - private val sosoPickAdapter: SosoPickAdapter by lazy { SosoPickAdapter(::navigateToNovelDetail) } - private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } - private val exploreViewModel: ExploreViewModel by viewModels() - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - initSosoPickAdapter() - onNormalSearchButtonClick() - onDetailExploreButtonClick() - onReloadPageButtonClick() - setupObserver() - tracker.trackEvent("search") - } - - private fun initSosoPickAdapter() { - binding.rvExploreSosoPick.adapter = sosoPickAdapter - binding.rvExploreSosoPick.setHasFixedSize(true) - } - - private fun navigateToNovelDetail(novelId: Long) { - tracker.trackEvent( - eventName = "soso_pick", - properties = mapOf("novelId" to novelId), - ) - val intent = NovelDetailActivity.getIntent(requireContext(), novelId) - startActivity(intent) - } - - private fun onNormalSearchButtonClick() { - binding.clExploreNormalSearch.setOnClickListener { - tracker.trackEvent("general_search") - val intent = NormalExploreActivity.getIntent(requireContext()) - startActivity(intent) - } - } - - private fun onDetailExploreButtonClick() { - binding.clExploreDetailSearch.setOnClickListener { - singleEventHandler.throttleFirst { - showDetailExploreDialog() - } - } - } - - private fun showDetailExploreDialog() { - val detailExploreBottomSheet = DetailExploreDialogBottomSheet.newInstance() - detailExploreBottomSheet.show(childFragmentManager, DETAIL_BOTTOM_SHEET_TAG) - } - - private fun onReloadPageButtonClick() { - binding.wllExplore.setReloadButtonClickListener { - binding.wllExplore.setErrorLayoutVisibility(false) - exploreViewModel.updateSosoPicks() - } - } - - private fun setupObserver() { - exploreViewModel.uiState.observe(viewLifecycleOwner) { uiState -> - when { - uiState.loading -> binding.wllExplore.setWebsosoLoadingVisibility(true) - uiState.error -> binding.wllExplore.setLoadingLayoutVisibility(false) - !uiState.loading -> { - binding.wllExplore.setWebsosoLoadingVisibility(false) - sosoPickAdapter.submitList(uiState.sosoPicks) - } - } - } - } - - companion object { - private const val DETAIL_BOTTOM_SHEET_TAG = "DetailExploreDialogBottomSheet" - const val TAG = "ExploreFragment" - } -} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index e400af729..84c069b84 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -19,16 +19,16 @@ import com.into.websoso.core.common.ui.model.ResultFrom.NormalExploreBack import com.into.websoso.core.common.ui.model.ResultFrom.Notification import com.into.websoso.core.common.ui.model.ResultFrom.NovelDetailBack import com.into.websoso.core.common.ui.model.ResultFrom.ProfileEditSuccess +import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.core.common.util.tracker.Tracker -import com.into.websoso.core.resource.R.string.home_nickname_interest_feed import com.into.websoso.databinding.FragmentHomeBinding +import com.into.websoso.ui.detailExplore.DetailExploreActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity import com.into.websoso.ui.main.MainViewModel import com.into.websoso.ui.main.home.adpater.PopularFeedsAdapter import com.into.websoso.ui.main.home.adpater.PopularNovelsAdapter import com.into.websoso.ui.main.home.adpater.RecommendedNovelsByUserTasteAdapter -import com.into.websoso.ui.main.home.adpater.UserInterestFeedAdapter import com.into.websoso.ui.main.home.dialog.TermsAgreementDialogFragment import com.into.websoso.ui.normalExplore.NormalExploreActivity import com.into.websoso.ui.notification.NotificationActivity @@ -45,6 +45,8 @@ class HomeFragment : BaseFragment(fragment_home) { private val homeViewModel: HomeViewModel by viewModels() private val mainViewModel: MainViewModel by activityViewModels() + private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } + private val homeResultLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> when (result.resultCode) { @@ -62,10 +64,6 @@ class HomeFragment : BaseFragment(fragment_home) { PopularFeedsAdapter(::onPopularFeedClick) } - private val userInterestFeedAdapter: UserInterestFeedAdapter by lazy { - UserInterestFeedAdapter(::onUserInterestNovelFeedClick) - } - private val recommendedNovelsByUserTasteAdapter: RecommendedNovelsByUserTasteAdapter by lazy { RecommendedNovelsByUserTasteAdapter(::onRecommendedNovelClick) } @@ -105,10 +103,10 @@ class HomeFragment : BaseFragment(fragment_home) { setupItemDecoration() setupObserver() setupDotsIndicator() - onPostInterestNovelClick() onSettingPreferenceGenreClick() onNotificationButtonClick() onNormalSearchButtonClick() + onDetailSearchButtonClick() tracker.trackEvent("home") } @@ -120,6 +118,14 @@ class HomeFragment : BaseFragment(fragment_home) { } } + private fun onDetailSearchButtonClick() { + binding.clHomeDetailSearch.setOnClickListener { + singleEventHandler.throttleFirst { + startActivity(DetailExploreActivity.getIntent(requireContext())) + } + } + } + private fun bindViewModel() { binding.viewModel = homeViewModel binding.lifecycleOwner = this @@ -129,28 +135,17 @@ class HomeFragment : BaseFragment(fragment_home) { with(binding) { rvHomeTodayPopularNovel.adapter = popularNovelsAdapter vpHomePopularFeed.adapter = popularFeedsAdapter - rvUserInterestFeed.adapter = userInterestFeedAdapter rvRecommendNovelByUserTaste.adapter = recommendedNovelsByUserTasteAdapter } } private fun setupItemDecoration() { - with(binding) { - rvHomeTodayPopularNovel.addItemDecoration( - HomeCustomItemDecoration( - TODAY_POPULAR_NOVEL_MARGIN, - ), - ) - rvUserInterestFeed.addItemDecoration(HomeCustomItemDecoration(USER_INTEREST_MARGIN)) - } + binding.rvHomeTodayPopularNovel.addItemDecoration( + HomeCustomItemDecoration(TODAY_POPULAR_NOVEL_MARGIN), + ) } private fun setupObserver() { - mainViewModel.mainUiState.observe(viewLifecycleOwner) { uiState -> - binding.tvHomeInterestFeed.text = - getString(home_nickname_interest_feed, uiState.nickname) - } - homeViewModel.uiState.observe(viewLifecycleOwner) { uiState -> when { uiState.error -> { @@ -162,9 +157,7 @@ class HomeFragment : BaseFragment(fragment_home) { binding.wllHome.setWebsosoLoadingVisibility(false) popularNovelsAdapter.submitList(uiState.popularNovels) popularFeedsAdapter.submitList(uiState.popularFeeds) - updateUserInterestFeedsVisibility(uiState.userInterestFeeds.isEmpty()) updateRecommendedNovelByUserTasteVisibility(uiState.recommendedNovelsByUserTaste.isEmpty()) - userInterestFeedAdapter.submitList(uiState.userInterestFeeds) recommendedNovelsByUserTasteAdapter.submitList(uiState.recommendedNovelsByUserTaste) updateHasNotificationUnread(uiState.isNotificationUnread) } @@ -193,32 +186,6 @@ class HomeFragment : BaseFragment(fragment_home) { } } - private fun updateUserInterestFeedsVisibility(isUserInterestEmpty: Boolean) { - with(binding) { - if (isUserInterestEmpty) { - when (homeViewModel.uiState.value?.isInterestNovel) { - true -> { - clHomeUserInterestFeed.visibility = View.GONE - clHomeInterestFeed.visibility = View.GONE - clHomeNoAssociatedFeed.visibility = View.VISIBLE - } - - false -> { - clHomeUserInterestFeed.visibility = View.GONE - clHomeInterestFeed.visibility = View.VISIBLE - clHomeNoAssociatedFeed.visibility = View.GONE - } - - else -> Unit - } - } else { - clHomeUserInterestFeed.visibility = View.VISIBLE - clHomeInterestFeed.visibility = View.GONE - clHomeNoAssociatedFeed.visibility = View.GONE - } - } - } - private fun updateRecommendedNovelByUserTasteVisibility(isRecommendNovelByUserTasteEmpty: Boolean) { with(binding) { if (isRecommendNovelByUserTasteEmpty) { @@ -268,11 +235,6 @@ class HomeFragment : BaseFragment(fragment_home) { navigateToNovelDetail(novelId) } - private fun onUserInterestNovelFeedClick(novelId: Long) { - tracker.trackEvent("home_love_feedlist") - navigateToNovelDetail(novelId) - } - private fun onRecommendedNovelClick(novelId: Long) { tracker.trackEvent("home_prefer_novellist") navigateToNovelDetail(novelId) @@ -301,17 +263,6 @@ class HomeFragment : BaseFragment(fragment_home) { ) } - private fun onPostInterestNovelClick() { - binding.clHomeInterestFeed.setOnClickListener { - tracker.trackEvent("home_to_love_btn") - startActivityLauncher.launch( - NormalExploreActivity.getIntent( - requireContext(), - ), - ) - } - } - private fun onSettingPreferenceGenreClick() { binding.clHomeRecommendNovel.setOnClickListener { tracker.trackEvent("home_to_prefer_btn") @@ -341,7 +292,6 @@ class HomeFragment : BaseFragment(fragment_home) { companion object { private const val TODAY_POPULAR_NOVEL_MARGIN = 15 - private const val USER_INTEREST_MARGIN = 14 const val TAG = "HomeFragment" } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 99c2f6ea0..c17f6ffe0 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -9,9 +9,6 @@ import com.into.websoso.data.model.PopularFeedsEntity import com.into.websoso.data.model.PopularNovelsEntity import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity import com.into.websoso.data.model.TermsAgreementEntity -import com.into.websoso.data.model.UserInterestFeedMessage -import com.into.websoso.data.model.UserInterestFeedMessage.NO_INTEREST_NOVELS -import com.into.websoso.data.model.UserInterestFeedsEntity import com.into.websoso.data.repository.FeedRepository import com.into.websoso.data.repository.NotificationRepository import com.into.websoso.data.repository.NovelRepository @@ -78,11 +75,9 @@ class HomeViewModel val results = listOf( async { runCatching { novelRepository.fetchPopularNovels() } }, async { runCatching { feedRepository.fetchPopularFeeds() } }, - async { runCatching { feedRepository.fetchUserInterestFeeds() } }, async { runCatching { novelRepository.fetchRecommendedNovelsByUserTaste() } }, ).awaitAll() - // 실패가 하나라도 있다면 상위 onFailure로 예외 전파 val failures = results.filter { it.isFailure } if (failures.isNotEmpty()) { throw failures.first().exceptionOrNull() @@ -93,10 +88,8 @@ class HomeViewModel ?: PopularNovelsEntity(emptyList()) val popularFeeds = results[1].getOrNull() as? PopularFeedsEntity ?: PopularFeedsEntity(emptyList()) - val userInterestFeeds = results[2].getOrNull() as? UserInterestFeedsEntity - ?: UserInterestFeedsEntity(emptyList(), "") val recommendedNovels = - results[3].getOrNull() as? RecommendedNovelsByUserTasteEntity + results[2].getOrNull() as? RecommendedNovelsByUserTasteEntity ?: RecommendedNovelsByUserTasteEntity(emptyList()) _uiState.value = uiState.value?.copy( @@ -104,8 +97,6 @@ class HomeViewModel error = false, popularNovels = popularNovels.popularNovels, popularFeeds = popularFeeds.popularFeeds.chunked(3), - isInterestNovel = isUserInterestedInNovels(userInterestFeeds.message), - userInterestFeeds = userInterestFeeds.userInterestFeeds, recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, ) }.onFailure { @@ -165,23 +156,13 @@ class HomeViewModel fun updateFeed() { viewModelScope.launch { runCatching { - listOf( - async { feedRepository.fetchPopularFeeds() }, - async { feedRepository.fetchUserInterestFeeds() }, - ).awaitAll() - }.onSuccess { responses -> - val popularFeeds = responses[0] as PopularFeedsEntity - val userInterestFeeds = responses[1] as UserInterestFeedsEntity - + feedRepository.fetchPopularFeeds() + }.onSuccess { popularFeeds -> _uiState.value = uiState.value?.copy( popularFeeds = popularFeeds.popularFeeds.chunked(3), - isInterestNovel = isUserInterestedInNovels(userInterestFeeds.message), - userInterestFeeds = userInterestFeeds.userInterestFeeds, ) }.onFailure { - _uiState.value = uiState.value?.copy( - error = true, - ) + _uiState.value = uiState.value?.copy(error = true) } } } @@ -225,12 +206,6 @@ class HomeViewModel } } - private fun isUserInterestedInNovels(userInterestFeedMessage: String): Boolean = - when (UserInterestFeedMessage.fromMessage(userInterestFeedMessage)) { - NO_INTEREST_NOVELS -> false - else -> true - } - fun saveFCMToken(token: String) { viewModelScope.launch { runCatching { diff --git a/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedAdapter.kt b/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedAdapter.kt deleted file mode 100644 index 6c3716f2c..000000000 --- a/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.into.websoso.ui.main.home.adpater - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.into.websoso.data.model.UserInterestFeedsEntity.UserInterestFeedEntity - -class UserInterestFeedAdapter( - private val onUserInterestFeedClick: (novelId: Long) -> (Unit), -) : ListAdapter(diffUtil) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): UserInterestFeedViewHolder = UserInterestFeedViewHolder.of(parent, onUserInterestFeedClick) - - override fun onBindViewHolder( - holder: UserInterestFeedViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - - val params = holder.itemView.layoutParams as RecyclerView.LayoutParams - params.width = (holder.itemView.context.resources.displayMetrics.widthPixels * 0.8).toInt() - holder.itemView.layoutParams = params - } - - companion object { - val diffUtil = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: UserInterestFeedEntity, - newItem: UserInterestFeedEntity, - ): Boolean = oldItem.novelId == newItem.novelId - - override fun areContentsTheSame( - oldItem: UserInterestFeedEntity, - newItem: UserInterestFeedEntity, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedViewHolder.kt b/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedViewHolder.kt deleted file mode 100644 index b1dfa8aed..000000000 --- a/app/src/main/java/com/into/websoso/ui/main/home/adpater/UserInterestFeedViewHolder.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.into.websoso.ui.main.home.adpater - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.into.websoso.core.common.util.getS3ImageUrl -import com.into.websoso.data.model.UserInterestFeedsEntity.UserInterestFeedEntity -import com.into.websoso.databinding.ItemUserInterestFeedBinding - -class UserInterestFeedViewHolder( - private val binding: ItemUserInterestFeedBinding, - onUserInterestFeedClick: (novelId: Long) -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.onClick = onUserInterestFeedClick - } - - fun bind(userInterestFeed: UserInterestFeedEntity) { - val updateUserInterestFeed = userInterestFeed.copy( - avatarImage = itemView.getS3ImageUrl(userInterestFeed.avatarImage ?: ""), - ) - binding.userInterestFeed = updateUserInterestFeed - } - - companion object { - fun of( - parent: ViewGroup, - onUserInterestFeedClick: (novelId: Long) -> Unit, - ): UserInterestFeedViewHolder { - val binding = ItemUserInterestFeedBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return UserInterestFeedViewHolder(binding, onUserInterestFeedClick) - } - } -} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt index 609a79057..e3a5d451f 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt @@ -3,15 +3,12 @@ package com.into.websoso.ui.main.home.model import com.into.websoso.data.model.PopularFeedsEntity.PopularFeedEntity import com.into.websoso.data.model.PopularNovelsEntity.PopularNovelEntity import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity.RecommendedNovelByUserTasteEntity -import com.into.websoso.data.model.UserInterestFeedsEntity.UserInterestFeedEntity data class HomeUiState( val loading: Boolean = true, val error: Boolean = false, - val isInterestNovel: Boolean = false, val isNotificationUnread: Boolean = false, val popularNovels: List = listOf(), val popularFeeds: List> = listOf(), - val userInterestFeeds: List = listOf(), val recommendedNovelsByUserTaste: List = listOf(), ) diff --git a/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreActivity.kt b/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreActivity.kt index a4c77c655..a55c9b2aa 100644 --- a/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreActivity.kt @@ -17,6 +17,7 @@ import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.resource.R.string.novel_inquire_link import com.into.websoso.databinding.ActivityNormalExploreBinding +import com.into.websoso.ui.main.explore.adapter.SosoPickAdapter import com.into.websoso.ui.normalExplore.adapter.NormalExploreAdapter import com.into.websoso.ui.normalExplore.adapter.NormalExploreItemType.Header import com.into.websoso.ui.normalExplore.adapter.NormalExploreItemType.Loading @@ -37,6 +38,7 @@ class NormalExploreActivity : BaseActivity(activit ::navigateToInquire, ) } + private val sosoPickAdapter: SosoPickAdapter by lazy { SosoPickAdapter(::navigateToNovelDetailFromSosoPick) } private val normalExploreViewModel: NormalExploreViewModel by viewModels() private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } @@ -67,6 +69,7 @@ class NormalExploreActivity : BaseActivity(activit ), ) } + rvNormalExploreSosoPick.adapter = sosoPickAdapter onClick = onNormalExploreButtonClick() } } @@ -153,6 +156,14 @@ class NormalExploreActivity : BaseActivity(activit } } + private fun navigateToNovelDetailFromSosoPick(novelId: Long) { + singleEventHandler.throttleFirst { + tracker.trackEvent("soso_pick", mapOf("novelId" to novelId)) + val intent = NovelDetailActivity.getIntent(this, novelId) + startActivity(intent) + } + } + private fun navigateToInquire() { val inquireUrl = getString(novel_inquire_link) val intent = Intent(ACTION_VIEW, Uri.parse(inquireUrl)) @@ -183,6 +194,10 @@ class NormalExploreActivity : BaseActivity(activit normalExploreViewModel.searchWord.observe(this) { normalExploreViewModel.validateSearchWordClearButton() } + + normalExploreViewModel.sosoPicks.observe(this) { sosoPicks -> + sosoPickAdapter.submitList(sosoPicks) + } } private fun updateView(uiState: NormalExploreUiState) { diff --git a/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreViewModel.kt b/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreViewModel.kt index 47972292c..5ee3a74a8 100644 --- a/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/normalExplore/NormalExploreViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.into.websoso.data.model.SosoPickEntity +import com.into.websoso.data.repository.NovelRepository import com.into.websoso.domain.usecase.GetNormalExploreResultUseCase import com.into.websoso.ui.mapper.toUi import com.into.websoso.ui.normalExplore.NormalExploreActivity.Companion.SEARCH_AUTHOR @@ -18,6 +20,7 @@ class NormalExploreViewModel @Inject constructor( private val getNormalExploreResultUseCase: GetNormalExploreResultUseCase, + private val novelRepository: NovelRepository, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _uiState: MutableLiveData = @@ -36,12 +39,29 @@ class NormalExploreViewModel private val _isNovelResultEmptyBoxVisibility: MutableLiveData = MutableLiveData(false) val isNovelResultEmptyBoxVisibility: LiveData get() = _isNovelResultEmptyBoxVisibility + private val _sosoPicks: MutableLiveData> = MutableLiveData(emptyList()) + val sosoPicks: LiveData> get() = _sosoPicks + + private val _isSosoPickVisible: MutableLiveData = MutableLiveData(initialSearchWord.isBlank()) + val isSosoPickVisible: LiveData get() = _isSosoPickVisible + init { + fetchSosoPicks() if (initialSearchWord.isNotBlank()) { updateSearchResult(isSearchButtonClick = true) } } + private fun fetchSosoPicks() { + viewModelScope.launch { + runCatching { + novelRepository.fetchSosoPicks() + }.onSuccess { result -> + _sosoPicks.value = result.novels + } + } + } + fun updateSearchWord(searchWord: String) { _searchWord.value = searchWord savedStateHandle[SEARCH_AUTHOR] = searchWord @@ -51,6 +71,9 @@ class NormalExploreViewModel if ((_searchWord.value.isNullOrBlank() || _uiState.value?.isLoadable == false) && !isSearchButtonClick) { return } + if (isSearchButtonClick) { + _isSosoPickVisible.value = false + } viewModelScope.launch { _uiState.value = _uiState.value?.copy(loading = isSearchButtonClick) runCatching { @@ -90,5 +113,6 @@ class NormalExploreViewModel fun updateSearchWordEmpty() { _searchWord.value = "" savedStateHandle[SEARCH_AUTHOR] = "" + _isSosoPickVisible.value = true } } diff --git a/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt b/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt index b095262f1..35b7577cf 100644 --- a/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt @@ -5,7 +5,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams import android.view.WindowManager.LayoutParams.WRAP_CONTENT import android.widget.PopupWindow import android.widget.TextView @@ -341,17 +340,8 @@ class NovelFeedFragment : BaseFragment(R.layout.fragme } private fun setupRefreshView() { - binding.sptrNovelFeedRefresh.apply { - setRefreshViewParams( - params = LayoutParams( - 30.toIntPxFromDp(), - 30.toIntPxFromDp(), - ), - ) - setLottieAnimation("lottie_websoso_loading.json") - setOnRefreshListener { - novelFeedViewModel.updateRefreshedFeeds(novelId) - } + binding.sptrNovelFeedRefresh.setOnRefreshListener { + novelFeedViewModel.updateRefreshedFeeds(novelId) } } @@ -375,7 +365,7 @@ class NovelFeedFragment : BaseFragment(R.layout.fragme !novelFeedUiState.loading -> { binding.wllNovelFeed.setWebsosoLoadingVisibility(false) - binding.sptrNovelFeedRefresh.setRefreshing(false) + binding.sptrNovelFeedRefresh.isRefreshing = false updateFeeds(novelFeedUiState) } } diff --git a/app/src/main/res/layout/activity_normal_explore.xml b/app/src/main/res/layout/activity_normal_explore.xml index 26f1d334a..50c13ecb5 100644 --- a/app/src/main/res/layout/activity_normal_explore.xml +++ b/app/src/main/res/layout/activity_normal_explore.xml @@ -75,6 +75,7 @@ android:autofillHints="@null" android:background="@null" android:cursorVisible="true" + android:hint="@string/explore_normal_search_hint" android:imeOptions="actionSearch" android:inputType="text" android:maxLines="1" @@ -83,6 +84,7 @@ android:textAppearance="@style/label1" android:textColor="@color/black" android:textColorHighlight="@color/primary_50_F1EFFF" + android:textColorHint="@color/gray_200_949399" android:theme="@style/Theme.Websoso.EditText" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/iv_normal_explore_cancel_button" @@ -123,6 +125,69 @@ + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_detail_explore_info.xml b/app/src/main/res/layout/fragment_detail_explore_info.xml deleted file mode 100644 index f902b4aa6..000000000 --- a/app/src/main/res/layout/fragment_detail_explore_info.xml +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_detail_explore_keyword.xml b/app/src/main/res/layout/fragment_detail_explore_keyword.xml deleted file mode 100644 index 6e43cc3c6..000000000 --- a/app/src/main/res/layout/fragment_detail_explore_keyword.xml +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_detail_explore_result_info.xml b/app/src/main/res/layout/fragment_detail_explore_result_info.xml deleted file mode 100644 index 6f93908cb..000000000 --- a/app/src/main/res/layout/fragment_detail_explore_result_info.xml +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_detail_explore_result_keyword.xml b/app/src/main/res/layout/fragment_detail_explore_result_keyword.xml deleted file mode 100644 index 7ace9a82b..000000000 --- a/app/src/main/res/layout/fragment_detail_explore_result_keyword.xml +++ /dev/null @@ -1,255 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_explore.xml b/app/src/main/res/layout/fragment_explore.xml deleted file mode 100644 index 05cc959c8..000000000 --- a/app/src/main/res/layout/fragment_explore.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index b467f12bf..53ee70e1f 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -87,6 +87,65 @@ + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/cl_home_detail_search" /> - - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/dotsIndicator_home" /> - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_main_bnv.xml b/app/src/main/res/menu/menu_main_bnv.xml index 1b19c67c9..825d8a2db 100644 --- a/app/src/main/res/menu/menu_main_bnv.xml +++ b/app/src/main/res/menu/menu_main_bnv.xml @@ -5,11 +5,6 @@ android:icon="@drawable/ic_main_home" android:title="@string/main_ic_home" /> - - , - @SerialName("message") - val message: String, -) { - @Serializable - data class UserInterestFeedResponseDto( - @SerialName("avatarImage") - val avatarImage: String, - @SerialName("feedContent") - val feedContent: String, - @SerialName("nickname") - val nickname: String, - @SerialName("novelId") - val novelId: Int, - @SerialName("novelImage") - val novelImage: String, - @SerialName("novelRating") - val novelRating: Double, - @SerialName("novelRatingCount") - val novelRatingCount: Int, - @SerialName("novelTitle") - val novelTitle: String, - ) -} diff --git a/core/resource/src/main/res/drawable-xxhdpi/img_home_detail_search.png b/core/resource/src/main/res/drawable-xxhdpi/img_home_detail_search.png new file mode 100644 index 000000000..304ca738a Binary files /dev/null and b/core/resource/src/main/res/drawable-xxhdpi/img_home_detail_search.png differ diff --git a/core/resource/src/main/res/drawable/ic_main_explore.xml b/core/resource/src/main/res/drawable/ic_main_explore.xml deleted file mode 100644 index 83e83c1d5..000000000 --- a/core/resource/src/main/res/drawable/ic_main_explore.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/core/resource/src/main/res/drawable/ic_plus_novel.xml b/core/resource/src/main/res/drawable/ic_plus_novel.xml new file mode 100644 index 000000000..31e4fab63 --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_plus_novel.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 364c8ca5b..3b156ac51 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -7,7 +7,6 @@ - 탐색 서재 피드 My @@ -21,11 +20,9 @@ 찾는 작품이 없다면? - 탐색하기 작품 제목, 작가를 검색하세요 뭐 읽을지 고민될 땐? 장르, 연재상태, 별점, 키워드로 작품 찾기 - 내 취향에 맞는 웹소설 찾기 소소 다른 독자들이 최근에 찾아본 웹소설이에요 @@ -46,6 +43,8 @@ %1$.1f이상 %1$.1f이상 %1$.1f이상 + %1$.1f ~ %2$.1f + %1$.1f 키워드를 검색하세요 @@ -234,10 +233,6 @@ + 오늘의 발견 + 지금 뜨는 글 - 관심글 - 관심 등록한 작품의 최신 글이에요 - 관심작품의 최신 소식을 모아서 볼 수 있어요.\n좋아하는 웹소설을 관심 등록 해볼까요? - 관심작품 등록하기 관심 등록한 작품의 최신 글이에요 로맨스, 로판, 판타지, 현판 등\n선호장르를 기반으로 웹소설을 추천해드려요! 선호장르를 기반으로 추천해드려요 @@ -246,12 +241,7 @@ %s님의 한마디 작품 소개 스포일러가 포함된 글 보기 - %1$s님의 한마디 - %1$.1f (%2$d) - ・ :*%1$s님의 관심글*: ・ %1$.1f (%2$d) - 관심 등록한 작품과 관련된 글이 없어요 - 관심글 설정 diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.kt b/data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.kt index 6ffe13643..68ae94e9f 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.kt @@ -6,14 +6,12 @@ import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailRe import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto import com.into.websoso.core.network.datasource.feed.model.response.PopularFeedsResponseDto -import com.into.websoso.core.network.datasource.feed.model.response.UserInterestFeedsResponseDto import com.into.websoso.data.feed.model.CommentEntity import com.into.websoso.data.feed.model.CommentsEntity import com.into.websoso.data.feed.model.FeedDetailEntity import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity import com.into.websoso.data.feed.model.PopularFeedsEntity -import com.into.websoso.data.feed.model.UserInterestFeedsEntity fun FeedsResponseDto.toData(): FeedsEntity = FeedsEntity( @@ -122,20 +120,3 @@ fun PopularFeedsResponseDto.toData(): PopularFeedsEntity = ) }, ) - -fun UserInterestFeedsResponseDto.toData(): UserInterestFeedsEntity = - UserInterestFeedsEntity( - userInterestFeeds = userInterestFeeds.map { feed -> - UserInterestFeedsEntity.UserInterestFeedEntity( - avatarImage = feed.avatarImage, - feedContent = feed.feedContent, - nickname = feed.nickname, - novelId = feed.novelId, - novelImage = feed.novelImage, - novelRating = feed.novelRating, - novelRatingCount = feed.novelRatingCount, - novelTitle = feed.novelTitle, - ) - }, - message = message, - ) diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/model/UserInterestFeedsEntity.kt b/data/feed/src/main/java/com/into/websoso/data/feed/model/UserInterestFeedsEntity.kt deleted file mode 100644 index f34c9967d..000000000 --- a/data/feed/src/main/java/com/into/websoso/data/feed/model/UserInterestFeedsEntity.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.into.websoso.data.feed.model - -data class UserInterestFeedsEntity( - val userInterestFeeds: List, - val message: String, -) { - data class UserInterestFeedEntity( - val avatarImage: String, - val feedContent: String, - val nickname: String, - val novelId: Int, - val novelImage: String, - val novelRating: Double, - val novelRatingCount: Int, - val novelTitle: String, - ) -} - -enum class UserInterestFeedMessage( - val message: String, -) { - NO_INTEREST_NOVELS("NO_INTEREST_NOVELS"), - NO_ASSOCIATED_FEEDS("NO_ASSOCIATED_FEEDS"), - ; - - companion object { - fun fromMessage(message: String): UserInterestFeedMessage? = entries.find { it.message == message } - } -} diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt index 81b8eb420..543452cb5 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt @@ -101,7 +101,7 @@ fun LibraryScreen( onSortClick = libraryViewModel::updateSortType, onToggleViewType = libraryViewModel::updateViewType, onItemClick = { navigateToNovelDetailActivity(it.novelId) }, - onSearchClick = { /* TODO */ }, + onSearchClick = navigateToNormalExploreActivity, onExploreClick = navigateToNormalExploreActivity, onInterestClick = libraryViewModel::updateInterestedNovels, onAttractivePointClick = libraryViewModel::updateAttractivePoints, diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryTopBar.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryTopBar.kt index b66081877..40f36fc25 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryTopBar.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryTopBar.kt @@ -1,23 +1,30 @@ package com.into.websoso.feature.library.component +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Black import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.core.resource.R.drawable.ic_plus_novel @Composable internal fun LibraryTopBar(onSearchClick: () -> Unit = {}) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp), + .padding(start = 20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -26,17 +33,16 @@ internal fun LibraryTopBar(onSearchClick: () -> Unit = {}) { style = WebsosoTheme.typography.headline1, color = Black, ) - /* - TODO: 추후 검색 기능 구현 시 롤백 - IconButton(onClick = onSearchClick) { - Image( - imageVector = ImageVector.vectorResource(id = ic_common_search), - contentDescription = "검색", - modifier = Modifier.size(24.dp), - ) - } - } - - */ + Box( + modifier = Modifier + .debouncedClickable(onClick = onSearchClick) + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + Image( + imageVector = ImageVector.vectorResource(id = ic_plus_novel), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 25217f871..d952d5fab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # App Versioning -versionCode = "10050" -versionName = "1.6.3" +versionCode = "10051" +versionName = "1.7.0" # Gradle Plugin & Kotlin android-gradle-plugin = "8.13.2" @@ -54,7 +54,7 @@ coil-transformers = "1.0.6" # Misc UI Libraries dots-indicator = "5.1.0" lottie = "6.7.1" -pull-to-refresh = "1.5.2" +swipe-refresh-layout = "1.2.0" # Social Login Libraries kakao = "2.23.1" @@ -130,7 +130,7 @@ coil-transformers = { module = "jp.wasabeef.transformers:coil", version.ref = "c # Misc UI Libraries dots-indicator = { module = "com.tbuonomo:dotsindicator", version.ref = "dots-indicator" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } -pull-to-refresh = { module = "com.github.SimformSolutionsPvtLtd:SSPullToRefresh", version.ref = "pull-to-refresh" } +swipe-refresh-layout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swipe-refresh-layout" } # Social Login Libraries kakao = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" }