diff --git a/core/database/src/main/java/com/chan/database/dao/ProductDao.kt b/core/database/src/main/java/com/chan/database/dao/ProductDao.kt index 6bbbabd..2ab1c73 100644 --- a/core/database/src/main/java/com/chan/database/dao/ProductDao.kt +++ b/core/database/src/main/java/com/chan/database/dao/ProductDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import com.chan.database.dto.CategoryDetailTabsDto import com.chan.database.dto.CategoryTabDto +import com.chan.database.dto.FilterCategoriesDto import com.chan.database.entity.ProductEntity @Dao @@ -43,7 +44,7 @@ interface ProductDao { } //세일 상품 가져오기 - suspend fun getSaleProducts(limit : Int): List { + suspend fun getSaleProducts(limit: Int): List { return getAll() .flatMap { it.categories } .flatMap { it.subCategories } @@ -61,7 +62,12 @@ interface ProductDao { category.subCategories.any { it.categoryId == subCategoryId } } ?.subCategories - ?.map { CategoryDetailTabsDto(categoryId = it.categoryId, categoryName = it.categoryName) } + ?.map { + CategoryDetailTabsDto( + categoryId = it.categoryId, + categoryName = it.categoryName + ) + } ?: emptyList() } @@ -86,4 +92,55 @@ interface ProductDao { .flatMap { it.products } .filter { it.productName.contains(query, ignoreCase = true) } } + + // 선택된 하위 카테고리 이름 목록으로 상품 필터링하기 + suspend fun getProductsBySubCategoryNames(subCategoryNames: Set): List { + if (subCategoryNames.isEmpty()) { + return emptyList() // 선택된 필터가 없으면 빈 리스트 반환 + } + return getAll() + .flatMap { it.categories } + .flatMap { it.subCategories } + .filter { subCategory -> subCategoryNames.contains(subCategory.categoryName) } + .flatMap { it.products } + .distinctBy { it.productId } // 여러 카테고리에 속한 동일 상품 중복 제거 + } + + // 선택된 하위 카테고리 이름 목록으로 상품 개수 세기 + suspend fun getProductCountBySubCategoryNames(subCategoryNames: Set): Int { + val subCategoriesWithProducts = getAll() + .flatMap { it.categories } + .flatMap { it.subCategories } + + val products = if (subCategoryNames.isEmpty()) { + // 선택된 필터가 없으면 전체 상품이 대상 + subCategoriesWithProducts.flatMap { it.products } + } else { + // 선택된 필터가 있으면 해당 카테고리의 상품만 대상 + subCategoriesWithProducts + .filter { subCategory -> subCategoryNames.contains(subCategory.categoryName) } + .flatMap { it.products } + } + // 중복 제거 후 개수 반환 + return products.distinctBy { it.productId }.size + } + + //필터 카테고리 정보 가져오기 + suspend fun getFilterCategories(): List { + return getAll() + .flatMap { productEntity -> productEntity.categories } + .distinctBy { category -> category.name } + .map { category -> + FilterCategoriesDto( + categoryId = category.id, + name = category.name, + subCategories = category.subCategories.map { subCategory -> + FilterCategoriesDto.SubCategoryDto( + subCategoryId = subCategory.categoryId, + subCategoryName = subCategory.categoryName + ) + } + ) + } + } } \ No newline at end of file diff --git a/core/database/src/main/java/com/chan/database/dto/FilterCategoriesDto.kt b/core/database/src/main/java/com/chan/database/dto/FilterCategoriesDto.kt new file mode 100644 index 0000000..dc8f408 --- /dev/null +++ b/core/database/src/main/java/com/chan/database/dto/FilterCategoriesDto.kt @@ -0,0 +1,12 @@ +package com.chan.database.dto + +data class FilterCategoriesDto( + val categoryId: String, + val name: String, + val subCategories: List +) { + data class SubCategoryDto( + val subCategoryId: String, + val subCategoryName: String, + ) +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/data/mappers/CategoryFilterMapper.kt b/feature/search/src/main/java/com/chan/search/data/mappers/CategoryFilterMapper.kt new file mode 100644 index 0000000..ec64d4a --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/data/mappers/CategoryFilterMapper.kt @@ -0,0 +1,18 @@ +package com.chan.search.data.mappers + +import com.chan.database.dto.FilterCategoriesDto +import com.chan.search.domain.model.FilterCategoriesVO +import com.chan.search.domain.model.SubCategoryVO + +fun FilterCategoriesDto.toCategoryFilterDomain(): FilterCategoriesVO = + FilterCategoriesVO( + categoryId = this.categoryId, + name = this.name, + subCategories = this.subCategories.map { + SubCategoryVO( + subCategoryId = it.subCategoryId, + subCategoryName = it.subCategoryName + ) + } + ) + diff --git a/feature/search/src/main/java/com/chan/search/data/mappers/SearchToDomainMapper.kt b/feature/search/src/main/java/com/chan/search/data/mappers/SearchToDomainMapper.kt index 378f124..75347ca 100644 --- a/feature/search/src/main/java/com/chan/search/data/mappers/SearchToDomainMapper.kt +++ b/feature/search/src/main/java/com/chan/search/data/mappers/SearchToDomainMapper.kt @@ -1,10 +1,11 @@ package com.chan.search.data.mappers +import com.chan.database.dto.FilterCategoriesDto import com.chan.database.entity.ProductEntity import com.chan.database.entity.search.SearchHistoryEntity import com.chan.domain.ProductVO +import com.chan.search.domain.model.FilterCategoriesVO import com.chan.search.domain.model.SearchHistoryVO -import com.chan.search.ui.model.SearchHistoryModel fun ProductEntity.Categories.SubCategories.Products.toDomain(): ProductVO { return ProductVO( diff --git a/feature/search/src/main/java/com/chan/search/data/repository/SearchRepositoryImpl.kt b/feature/search/src/main/java/com/chan/search/data/repository/SearchRepositoryImpl.kt index 271c0b6..0582903 100644 --- a/feature/search/src/main/java/com/chan/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/src/main/java/com/chan/search/data/repository/SearchRepositoryImpl.kt @@ -3,8 +3,10 @@ package com.chan.search.data.repository import com.chan.database.dao.ProductDao import com.chan.database.dao.SearchHistoryDao import com.chan.domain.ProductVO +import com.chan.search.data.mappers.toCategoryFilterDomain import com.chan.search.data.mappers.toDomain import com.chan.search.data.mappers.toSearchHistoryEntity +import com.chan.search.domain.model.FilterCategoriesVO import com.chan.search.domain.model.SearchHistoryVO import com.chan.search.domain.repository.SearchRepository import kotlinx.coroutines.flow.Flow @@ -44,4 +46,16 @@ class SearchRepositoryImpl @Inject constructor( override suspend fun getSearchResultProducts(search: String): List { return productDao.searchProductsByName(search).map { it.toDomain() } } + + override suspend fun getFilterCategories(): List { + return productDao.getFilterCategories().map { it.toCategoryFilterDomain() } + } + + override suspend fun getFilteredProducts(subCategoryNames: Set): List { + return productDao.getProductsBySubCategoryNames(subCategoryNames).map { it.toDomain() } + } + + override suspend fun getFilteredProductCount(subCategoryNames: Set): Int { + return productDao.getProductCountBySubCategoryNames(subCategoryNames) + } } \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/domain/model/FilterCategoriesVO.kt b/feature/search/src/main/java/com/chan/search/domain/model/FilterCategoriesVO.kt new file mode 100644 index 0000000..b70328d --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/domain/model/FilterCategoriesVO.kt @@ -0,0 +1,12 @@ +package com.chan.search.domain.model + +data class FilterCategoriesVO( + val categoryId: String, + val name: String, + val subCategories: List +) + +data class SubCategoryVO( + val subCategoryId: String, + val subCategoryName: String, +) \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/domain/repository/SearchRepository.kt b/feature/search/src/main/java/com/chan/search/domain/repository/SearchRepository.kt index bfedfd5..9cde7ab 100644 --- a/feature/search/src/main/java/com/chan/search/domain/repository/SearchRepository.kt +++ b/feature/search/src/main/java/com/chan/search/domain/repository/SearchRepository.kt @@ -1,6 +1,7 @@ package com.chan.search.domain.repository import com.chan.domain.ProductVO +import com.chan.search.domain.model.FilterCategoriesVO import com.chan.search.domain.model.SearchHistoryVO import kotlinx.coroutines.flow.Flow @@ -13,4 +14,8 @@ interface SearchRepository { suspend fun clearAll() suspend fun getSearchResultProducts(search: String): List + + suspend fun getFilterCategories(): List + suspend fun getFilteredProducts(subCategoryNames: Set): List + suspend fun getFilteredProductCount(subCategoryNames: Set): Int } \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/RecentSearchList.kt b/feature/search/src/main/java/com/chan/search/ui/composables/RecentSearchList.kt index adc30a6..ffe4336 100644 --- a/feature/search/src/main/java/com/chan/search/ui/composables/RecentSearchList.kt +++ b/feature/search/src/main/java/com/chan/search/ui/composables/RecentSearchList.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -40,7 +41,8 @@ import com.chan.search.ui.model.SearchHistoryModel fun RecentSearchList( recentSearches: List, onRemoveSearch: (String) -> Unit, - onClearAllRecentSearches: () -> Unit + onClearAllRecentSearches: () -> Unit, + onSearchClick: (String) -> Unit, ) { Column(modifier = Modifier.padding(Spacing.spacing4)) { Row( @@ -70,7 +72,8 @@ fun RecentSearchList( recentSearches.forEach { search -> RecentSearchChip( keyword = search.search, - onRemove = { onRemoveSearch(search.search) } + onRemove = { onRemoveSearch(search.search) }, + onSearchClick = { onSearchClick(it) } ) } } @@ -80,17 +83,20 @@ fun RecentSearchList( @Composable fun RecentSearchChip( keyword: String, - onRemove: () -> Unit + onRemove: () -> Unit, + onSearchClick: (String) -> Unit ) { Surface( shape = RoundedCornerShape(Radius.radius6), color = Color.White, - border = BorderStroke(1.dp, LightGray.copy(alpha = 0.2f)) + border = BorderStroke(1.dp, LightGray.copy(alpha = 0.2f)), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(horizontal = Spacing.spacing3, vertical = Spacing.spacing2) + .padding(horizontal = Spacing.spacing3, vertical = Spacing.spacing2).clickable { + onSearchClick(keyword) + } ) { Text(text = keyword, style = MaterialTheme.appTypography.searchChip) IconButton( diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/SearchFilterScreen.kt b/feature/search/src/main/java/com/chan/search/ui/composables/SearchFilterScreen.kt new file mode 100644 index 0000000..0e4e192 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/composables/SearchFilterScreen.kt @@ -0,0 +1,281 @@ +package com.chan.search.ui.composables + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.chan.android.ui.theme.Spacing +import com.chan.android.ui.theme.White +import com.chan.search.R +import com.chan.search.ui.model.filter.DeliveryOption +import com.chan.search.ui.model.filter.FilterCategoriesModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchFilterScreen( + selectedDeliveryOption: DeliveryOption?, + categoryFilters: List, + expandedCategoryName: String?, + selectedSubCategories: Set, + isCategorySectionExpanded: Boolean, + filteredProductCount: Int, + onClose: () -> Unit, + onDeliveryOptionClick: (DeliveryOption) -> Unit, + onCategoryHeaderClick: (String) -> Unit, + onSubCategoryClick: (String) -> Unit, + onFilterCategoryClick: () -> Unit, + onFilterClear: () -> Unit, + modifier: Modifier = Modifier +) { + + Scaffold( + modifier = modifier, + containerColor = White, + topBar = { FilterHeader( + onClose = onClose, + onFilterClear = onFilterClear + ) }, + bottomBar = { FilterBottomButton( + itemCount = filteredProductCount, + onClick = onClose + ) } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + ) { + // "오늘드림", "픽업" 체크박스 섹션 + item { + FilterToggleSection( + selectedOption = selectedDeliveryOption, + onOptionClick = onDeliveryOptionClick + ) + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.2f), + modifier = Modifier + .padding(vertical = Spacing.spacing2), + thickness = Spacing.spacing2 + ) + } + + item { + //카테고리 + ExpandableFilterSection( + title = stringResource(R.string.category_label), + isExpanded = isCategorySectionExpanded, + onClick = onFilterCategoryClick + ) + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.5f), + thickness = 1.dp + ) + //서브 카테고리 + if (isCategorySectionExpanded) { + SubFilterCategory( + categoryFilters = categoryFilters, + expandedCategoryName = expandedCategoryName, + selectedSubCategories = selectedSubCategories, + onCategoryHeaderClick = onCategoryHeaderClick, + onSubCategoryClick = onSubCategoryClick + ) + } + } + + item { + ExpandableFilterSection(title = stringResource(R.string.price_label)) + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.5f), + thickness = 1.dp + ) + } + + item { + ExpandableFilterSection(title = stringResource(R.string.product_view_mode_label), details = "2단") + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.5f), + thickness = 1.dp + ) + } + } + } +} + +@Composable +private fun SubFilterCategory( + categoryFilters: List, + expandedCategoryName: String?, + selectedSubCategories: Set, + onCategoryHeaderClick: (String) -> Unit, + onSubCategoryClick: (String) -> Unit, +) { + Column { + categoryFilters.forEach { category -> + ExpandableFilterSection( + title = category.name, + isExpanded = expandedCategoryName == category.name, + onClick = { onCategoryHeaderClick(category.name) } + ) + if (expandedCategoryName == category.name) { + Column(modifier = Modifier.padding(start = Spacing.spacing4)) { + category.subCategories.forEach { subCategory -> + FilterCheckboxItem( + label = subCategory.subCategoryName, + checked = selectedSubCategories.contains(subCategory.subCategoryName), + onOptionClick = { onSubCategoryClick(subCategory.subCategoryName) } + ) + } + } + } + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.5f), + thickness = 1.dp + ) + } + } +} + +// 상단 헤더: "필터", 초기화, 닫기 버튼 +@Composable +fun FilterHeader( + onClose: () -> Unit, + onFilterClear: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "필터", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "필터 초기화", + modifier = Modifier + .size(28.dp) + .clickable(onClick = onFilterClear) + ) + Spacer(Modifier.width(16.dp)) + Icon( + imageVector = Icons.Default.Close, + contentDescription = "필터 닫기", + modifier = Modifier + .size(28.dp) + .clickable { onClose() } + ) + } +} + +// "오늘드림", "픽업" 체크박스 영역 +@Composable +fun FilterToggleSection(selectedOption: DeliveryOption?, onOptionClick: (DeliveryOption) -> Unit) { + Column(modifier = Modifier.padding(horizontal = Spacing.spacing4)) { + FilterCheckboxItem(label = stringResource(R.string.today_delivery_label), + checked = selectedOption == DeliveryOption.TODAY_DELIVERY, + onOptionClick = { onOptionClick(DeliveryOption.TODAY_DELIVERY) } + ) + FilterCheckboxItem(label = stringResource(R.string.pick_up_label), + checked = selectedOption == DeliveryOption.PICKUP, onOptionClick = { + onOptionClick(DeliveryOption.PICKUP) + }) + } +} + +@Composable +fun FilterCheckboxItem(label: String, checked: Boolean, onOptionClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = Spacing.spacing3) + .clickable(onClick = onOptionClick) + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + colors = CheckboxDefaults.colors( + uncheckedColor = Color.Gray, + checkedColor = Color.Black + ) + ) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +fun ExpandableFilterSection( + title: String, + details: String? = null, + isExpanded: Boolean = false, + onClick: () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(Spacing.spacing4), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.weight(1f)) + details?.let { subDetailText -> + Text( + text = subDetailText, + style = MaterialTheme.typography.bodyLarge, + color = Color.Gray, + modifier = Modifier.padding(end = Spacing.spacing2) + ) + } + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "더보기", + tint = Color.Gray + ) + } +} + +// 하단 "N개 상품 보기" 버튼 +@Composable +fun FilterBottomButton( + itemCount: Int, + onClick: () -> Unit +) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.spacing4), + shape = RoundedCornerShape(Spacing.spacing2), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White + ) + ) { + Text( + text = "%,d개 상품 보기".format(itemCount), + modifier = Modifier.padding(vertical = Spacing.spacing2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + } +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreen.kt b/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreen.kt index 897808d..140748e 100644 --- a/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreen.kt +++ b/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreen.kt @@ -5,8 +5,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController -import com.chan.search.ui.viewmodel.SearchViewModel import com.chan.search.ui.contract.SearchContract +import com.chan.search.ui.viewmodel.SearchViewModel @Composable fun SearchScreen( @@ -16,6 +16,7 @@ fun SearchScreen( val state by viewModel.viewState.collectAsState() SearchScreenContent( + navController = navController, search = state.search, recentSearches = state.recentSearches, recommendedKeywords = state.recommendedKeywords, @@ -24,12 +25,26 @@ fun SearchScreen( currentTime = state.currentTime, showSearchResult = state.showSearchResult, searchResultProducts = state.searchResultProducts, + showFilter = state.showFilter, + selectedDeliveryOption = state.selectedDeliveryOption, + categoryFilters = state.categoryFilters, + expandedCategoryName = state.expandedCategoryName, + selectedSubCategories = state.selectedSubCategories, + isCategorySectionExpanded = state.isCategorySectionExpanded, + filteredProductCount = state.filteredProductCount, onSearchChanged = { viewModel.setEvent(SearchContract.Event.OnSearchChanged(it)) }, onClearSearch = { viewModel.setEvent(SearchContract.Event.OnClickClearSearch) }, - onSearchClick = { viewModel.setEvent(SearchContract.Event.OnAddSearchKeyword(state.search)) }, + onSearchClick = { viewModel.setEvent(SearchContract.Event.OnAddSearchKeyword(it)) }, + onSearchTextFocus = { viewModel.setEvent(SearchContract.Event.OnSearchTextFocus) }, onRemoveSearchKeyword = { viewModel.setEvent(SearchContract.Event.OnRemoveSearchKeyword(it)) }, onClearAllRecentSearches = { viewModel.setEvent(SearchContract.Event.OnClearAllRecentSearches) }, - onSearchResultItemClick = { viewModel.setEvent(SearchContract.Event.OnClickSearchResult(it)) }, + onSearchResultItemClick = { viewModel.setEvent(SearchContract.Event.OnClickSearchProduct(it)) }, + onFilterClear = { viewModel.setEvent(SearchContract.Event.OnFilterClear) }, + onUpdateFilterClick = { viewModel.setEvent(SearchContract.Event.OnUpdateFilterClick) }, + onDeliveryOptionClick = { viewModel.setEvent(SearchContract.Event.OnDeliveryOptionChanged(it)) }, + onCategoryHeaderClick = { viewModel.setEvent(SearchContract.Event.OnCategoryHeaderClick(it)) }, + onSubCategoryClick = { viewModel.setEvent(SearchContract.Event.OnSubCategoryClick(it)) }, + onFilterCategoryClick = { viewModel.setEvent(SearchContract.Event.OnFilterCategoryClick) }, onClickBack = { navController.popBackStack() }, diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreenContent.kt b/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreenContent.kt index a4f0af9..75d1839 100644 --- a/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreenContent.kt +++ b/feature/search/src/main/java/com/chan/search/ui/composables/SearchScreenContent.kt @@ -1,8 +1,18 @@ package com.chan.search.ui.composables +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,90 +25,185 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import com.chan.android.model.ProductModel +import com.chan.android.ui.theme.Black import com.chan.android.ui.theme.Spacing import com.chan.android.ui.theme.White import com.chan.android.ui.theme.appTypography import com.chan.android.ui.theme.dividerColor +import com.chan.navigation.Routes import com.chan.search.R import com.chan.search.ui.composables.result.SearchResultScreen import com.chan.search.ui.model.SearchHistoryModel import com.chan.search.ui.model.SearchResultModel import com.chan.search.ui.model.TrendingSearchModel +import com.chan.search.ui.model.filter.DeliveryOption +import com.chan.search.ui.model.filter.FilterCategoriesModel @Composable fun SearchScreenContent( + navController: NavHostController, search: String, recentSearches: List, recommendedKeywords: List, trendingSearches: List, searchResults: List, currentTime: String, - showSearchResult : Boolean, - searchResultProducts : List, + showSearchResult: Boolean, + searchResultProducts: List, + showFilter: Boolean, + selectedDeliveryOption: DeliveryOption?, + categoryFilters: List, + expandedCategoryName: String?, + selectedSubCategories: Set, + isCategorySectionExpanded: Boolean, + filteredProductCount: Int, onSearchChanged: (String) -> Unit, onClearSearch: () -> Unit, - onSearchClick: () -> Unit, + onSearchClick: (String) -> Unit, + onSearchTextFocus: () -> Unit, onRemoveSearchKeyword: (String) -> Unit, onClearAllRecentSearches: () -> Unit, onSearchResultItemClick: (String) -> Unit, onClickBack: () -> Unit, onClickCart: () -> Unit, + onFilterClear: () -> Unit, + onUpdateFilterClick: () -> Unit, + onDeliveryOptionClick: (DeliveryOption) -> Unit, + onCategoryHeaderClick: (String) -> Unit, + onSubCategoryClick: (String) -> Unit, + onFilterCategoryClick: () -> Unit, ) { - Scaffold( - containerColor = White, - topBar = { - SearchTopAppBar( - onClickBack = onClickBack, - onClickCart = onClickCart - ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + containerColor = White, + topBar = { + SearchTopAppBar( + onClickBack = onClickBack, + onClickCart = onClickCart + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { - SearchTextField( - search = search, - onSearchChanged = onSearchChanged, - onClearSearch = onClearSearch, - onSearchClick = onSearchClick, - modifier = Modifier.padding(Spacing.spacing4) - ) - HorizontalDivider(color = dividerColor, thickness = 1.dp) + SearchTextField( + search = search, + onSearchChanged = onSearchChanged, + onClearSearch = onClearSearch, + onSearchClick = { + onSearchClick(it) + }, + onSearchTextFocus = { + onSearchTextFocus() + }, + modifier = Modifier.padding(Spacing.spacing4) + ) + HorizontalDivider(color = dividerColor, thickness = 1.dp) - if(!showSearchResult) { - if (search.isBlank()) { - if (recentSearches.isNotEmpty()) { - RecentSearchList( - recentSearches = recentSearches, - onRemoveSearch = onRemoveSearchKeyword, - onClearAllRecentSearches = onClearAllRecentSearches, + if (!showSearchResult) { + if (search.isBlank()) { + if (recentSearches.isNotEmpty()) { + RecentSearchList( + recentSearches = recentSearches, + onRemoveSearch = onRemoveSearchKeyword, + onClearAllRecentSearches = onClearAllRecentSearches, + onSearchClick = { + onSearchClick(it) + }, + ) + } + RecommendedKeywordList(recommendedKeywords = recommendedKeywords) + TrendingSearchList( + trendingSearches = trendingSearches, + currentTime = currentTime + ) + } else { + SearchResultList( + results = searchResults, + searchQuery = search, + onSearchResultItemClick = onSearchResultItemClick ) } - RecommendedKeywordList(recommendedKeywords = recommendedKeywords) - TrendingSearchList( - trendingSearches = trendingSearches, - currentTime = currentTime - ) } else { - SearchResultList( - results = searchResults, - searchQuery = search, - onSearchResultItemClick = onSearchResultItemClick - ) + if (searchResultProducts.isEmpty()) { + Text(text = stringResource(R.string.search_empty_product)) + } else { + Box(modifier = Modifier.weight(1f)) { + SearchResultScreen( + products = searchResultProducts, + onNavigateToFilter = onUpdateFilterClick, + onProductClick = { productId -> + navController.navigate( + Routes.PRODUCT_DETAIL.productDetailRoute(productId) + ) + } + ) + } + } } - } else { - SearchResultScreen(products = searchResultProducts) { + } + } - } + AnimatedVisibility( + visible = showFilter, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onUpdateFilterClick + ) + ) + } + + AnimatedVisibility( + visible = showFilter, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(300) + ), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(300) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + SearchFilterScreen( + selectedDeliveryOption = selectedDeliveryOption, + categoryFilters = categoryFilters, + expandedCategoryName = expandedCategoryName, + selectedSubCategories = selectedSubCategories, + isCategorySectionExpanded = isCategorySectionExpanded, + filteredProductCount = filteredProductCount, + onClose = onUpdateFilterClick, + onDeliveryOptionClick = onDeliveryOptionClick, + onCategoryHeaderClick = onCategoryHeaderClick, + onSubCategoryClick = onSubCategoryClick, + onFilterCategoryClick = onFilterCategoryClick, + onFilterClear = onFilterClear, + modifier = Modifier + .fillMaxWidth(0.8f) + .fillMaxHeight() + ) } } } diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/SearchTextField.kt b/feature/search/src/main/java/com/chan/search/ui/composables/SearchTextField.kt index fffd47a..daabe9a 100644 --- a/feature/search/src/main/java/com/chan/search/ui/composables/SearchTextField.kt +++ b/feature/search/src/main/java/com/chan/search/ui/composables/SearchTextField.kt @@ -19,8 +19,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import com.chan.android.ui.theme.Black import com.chan.android.ui.theme.Spacing @@ -32,15 +34,24 @@ fun SearchTextField( search: String, onSearchChanged: (String) -> Unit, onClearSearch: () -> Unit, - onSearchClick: () -> Unit, + onSearchClick: (String) -> Unit, + onSearchTextFocus: () -> Unit, modifier: Modifier = Modifier ) { + val focusManager = LocalFocusManager.current + + BasicTextField( value = search, onValueChange = onSearchChanged, modifier = modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .onFocusChanged { focusState -> + if (focusState.isFocused) { + onSearchTextFocus() + } + }, singleLine = true, cursorBrush = SolidColor(Black), decorationBox = { innerTextField -> @@ -80,7 +91,10 @@ fun SearchTextField( imageVector = Icons.Default.Search, contentDescription = "Search", modifier = Modifier - .clickable(onClick = onSearchClick) + .clickable { + onSearchClick(search) + focusManager.clearFocus() + } .padding(end = Spacing.spacing2) .size(Spacing.spacing5) ) diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreen.kt b/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreen.kt new file mode 100644 index 0000000..59a10a9 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreen.kt @@ -0,0 +1,34 @@ +package com.chan.search.ui.composables.result + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import com.chan.android.model.ProductModel +import com.chan.search.ui.contract.SearchContract +import com.chan.search.ui.viewmodel.SearchViewModel + +@Composable +fun SearchResultScreen( + viewModel: SearchViewModel = hiltViewModel(), + products: List, + onNavigateToFilter: () -> Unit, + onProductClick: (String) -> Unit +) { + val state by viewModel.viewState.collectAsState() + + + SearchResultScreenContent( + products = products, + filters = state.filterChips, + onToggleFilter = { + viewModel.setEvent(SearchContract.Event.OnFilterChipClicked(it)) + }, + onNavigateToFilter = { + onNavigateToFilter() + }, + onProductClick = { productId -> + onProductClick(productId) + } + ) +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreenContent.kt b/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreenContent.kt new file mode 100644 index 0000000..b808171 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/composables/result/SearchResultScreenContent.kt @@ -0,0 +1,310 @@ +package com.chan.search.ui.composables.result + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.chan.android.ProductCard +import com.chan.android.model.ProductModel +import com.chan.android.ui.theme.LightGray +import com.chan.android.ui.theme.Radius +import com.chan.android.ui.theme.Spacing +import com.chan.android.ui.theme.White +import com.chan.android.ui.theme.appTypography +import com.chan.android.ui.theme.dividerColor +import com.chan.search.ui.model.FilterChipType +import com.chan.search.ui.model.SearchResultFilterChipModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchResultScreenContent( + products: List, + filters: List, + onToggleFilter: (SearchResultFilterChipModel) -> Unit, + onNavigateToFilter: () -> Unit, + onProductClick: (String) -> Unit +) { + var selectedTabIndex by remember { mutableStateOf(0) } + val tabs = listOf("상품", "후기", "콘텐츠") + val lazyListState = rememberLazyListState() + + LaunchedEffect(products) { + if (products.isNotEmpty()) { + lazyListState.scrollToItem(0) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState + ) { + item { + SearchResultTabRow( + tabs = tabs, + selectedTabIndex = selectedTabIndex, + onTabSelected = { selectedTabIndex = it } + ) + } + + stickyHeader { + Surface( + modifier = Modifier.fillMaxWidth(), + color = White + ) { + Column { + FilterChipsRow( + filters = filters, + onToggleFilter = onToggleFilter, + onNavigateToFilter = onNavigateToFilter, + ) + HorizontalDivider(color = dividerColor, thickness = 1.dp) + } + } + } + + when (selectedTabIndex) { + 0 -> { // 상품 + item { + SearchResultListHeader(products) + } + productGrid( + products = products, + onProductClick = onProductClick + ) + } + + 1 -> { // 후기 + item { Text("후기 탭 내용") } + } + + 2 -> { // 콘텐츠 + item { Text("콘텐츠 탭 내용") } + } + } + } +} + +private fun LazyListScope.productGrid( + products: List, + onProductClick: (String) -> Unit +) { + val chunkedProducts = products.chunked(2) + items( + items = chunkedProducts, + key = { row -> row.joinToString { it.productId } } + ) { productRow -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.spacing2) + ) { + productRow.forEach { product -> + Box(modifier = Modifier.weight(1f)) { + ProductCard( + product = product, + onClick = { onProductClick(product.productId) }, + onLikeClick = {}, + onCartClick = {} + ) + } + } + if (productRow.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +fun CustomTab( + title: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + var textWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box( + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ) + .padding(horizontal = Spacing.spacing4) + ) { + Text( + text = title, + color = if (isSelected) Color.Black else Color.Gray, + style = MaterialTheme.appTypography.tab.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ), + onTextLayout = { textLayoutResult -> + textWidth = with(density) { textLayoutResult.size.width.toDp() } + }, + modifier = Modifier + .padding(vertical = Spacing.spacing4) + .align(Alignment.Center) + ) + + Box( + modifier = Modifier + .width(textWidth) + .height(2.dp) + .background(if (isSelected) Color.Black else Color.Transparent) + .align(Alignment.BottomCenter) + ) + } +} + +@Composable +private fun SearchResultTabRow( + tabs: List, + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit +) { + Column(modifier = Modifier.background(Color.White)) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + tabs.forEachIndexed { index, title -> + CustomTab( + title = title, + isSelected = index == selectedTabIndex, + onClick = { onTabSelected(index) }, + modifier = Modifier.weight(1f) + ) + } + } + HorizontalDivider(color = dividerColor, thickness = 1.dp) + } +} + +@Composable +private fun FilterChipsRow( + filters: List, + onToggleFilter: (SearchResultFilterChipModel) -> Unit, + onNavigateToFilter: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = Spacing.spacing4, vertical = Spacing.spacing2), + horizontalArrangement = Arrangement.spacedBy(Spacing.spacing2), + verticalAlignment = Alignment.CenterVertically + ) { + filters.forEach { filter -> + FilterChip( + filter = filter, + onToggleFilter = onToggleFilter, + onNavigateToFilter = onNavigateToFilter, + ) + } + } + } +} + +@Composable +private fun FilterChip( + filter: SearchResultFilterChipModel, + onToggleFilter: (SearchResultFilterChipModel) -> Unit, + onNavigateToFilter: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val containerColor = if (filter.isSelected) Color.Black else Color.White + val textColor = if (filter.isSelected) Color.White else Color.DarkGray + + Box( + modifier = Modifier + .background(color = containerColor, shape = RoundedCornerShape(Radius.radius6)) + .clip(RoundedCornerShape(Radius.radius6)) + .border(width = 1.dp, color = LightGray, shape = RoundedCornerShape(Radius.radius6)) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + when (filter.chipType) { + FilterChipType.TOGGLE -> onToggleFilter(filter) + FilterChipType.DROP_DOWN -> onNavigateToFilter() + } + } + ) + .padding(horizontal = Spacing.spacing3, vertical = Spacing.spacing2) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + when (filter.chipType) { + FilterChipType.TOGGLE -> { + AsyncImage( + model = filter.image, + contentDescription = filter.text, + modifier = Modifier + .size(Spacing.spacing5) + .padding(end = Spacing.spacing1) + ) + Text(text = filter.text, color = textColor) + } + + FilterChipType.DROP_DOWN -> { + Text( + text = filter.text, + color = textColor, + modifier = Modifier.padding(end = Spacing.spacing1) + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Dropdown", + tint = textColor, + modifier = Modifier.size(Spacing.spacing4) + ) + } + } + } + } +} + +@Composable +private fun SearchResultListHeader(products: List) { + Text(text = "총 ${products.size}개", modifier = Modifier.padding(Spacing.spacing4)) +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/ui/contract/SearchContract.kt b/feature/search/src/main/java/com/chan/search/ui/contract/SearchContract.kt index 3915f59..eb0b432 100644 --- a/feature/search/src/main/java/com/chan/search/ui/contract/SearchContract.kt +++ b/feature/search/src/main/java/com/chan/search/ui/contract/SearchContract.kt @@ -6,19 +6,32 @@ import com.chan.android.ViewEvent import com.chan.android.ViewState import com.chan.android.model.ProductModel import com.chan.search.ui.model.SearchHistoryModel +import com.chan.search.ui.model.SearchResultFilterChipModel import com.chan.search.ui.model.SearchResultModel import com.chan.search.ui.model.TrendingSearchModel +import com.chan.search.ui.model.filter.DeliveryOption +import com.chan.search.ui.model.filter.FilterCategoriesModel class SearchContract { sealed class Event : ViewEvent { data class OnSearchChanged(val search: String) : Event() object OnClickClearSearch : Event() - data class OnClickSearchResult(val clickedProductName: String) : Event() + object OnSearchTextFocus : Event() + data class OnClickSearchProduct(val clickedProductName: String) : Event() data class OnAddSearchKeyword(val search: String) : Event() data class OnRemoveSearchKeyword(val search: String) : Event() object OnClearAllRecentSearches : Event() + + object OnUpdateFilterClick : Event() + object OnFilterClear : Event() + + data class OnFilterChipClicked(val chip: SearchResultFilterChipModel) : Event() + data class OnDeliveryOptionChanged(val option: DeliveryOption) : Event() + data class OnCategoryHeaderClick(val categoryName: String) : Event() + data class OnSubCategoryClick(val subCategoryName: String) : Event() + object OnFilterCategoryClick : Event() } data class State( @@ -34,6 +47,16 @@ class SearchContract { val searchResultProducts: List = emptyList(), val currentTime: String = "", val showSearchResult: Boolean = false, + val showFilter: Boolean = false, + + val selectedDeliveryOption: DeliveryOption? = null, + val filterChips: List = emptyList(), + + val categoryFilters: List = emptyList(), + val expandedCategoryName: String? = null, + val selectedSubCategories: Set = emptySet(), + val isCategorySectionExpanded: Boolean = false, + val filteredProductCount: Int = 0 ) : ViewState sealed class Effect : ViewEffect { diff --git a/feature/search/src/main/java/com/chan/search/ui/mappers/CategoryFilterUiMapper.kt b/feature/search/src/main/java/com/chan/search/ui/mappers/CategoryFilterUiMapper.kt new file mode 100644 index 0000000..9773f26 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/mappers/CategoryFilterUiMapper.kt @@ -0,0 +1,18 @@ +package com.chan.search.ui.mappers + +import com.chan.search.domain.model.FilterCategoriesVO +import com.chan.search.ui.model.filter.FilterCategoriesModel +import com.chan.search.ui.model.filter.FilterCategoriesModel.SubCategoryUiModel + +fun FilterCategoriesVO.toUiModel(): FilterCategoriesModel = + FilterCategoriesModel( + categoryId = this.categoryId, + name = this.name, + subCategories = this.subCategories.map { + SubCategoryUiModel( + subCategoryId = it.subCategoryId, + subCategoryName = it.subCategoryName + ) + } + ) + diff --git a/feature/search/src/main/java/com/chan/search/ui/mappers/SearchToPresentationMapper.kt b/feature/search/src/main/java/com/chan/search/ui/mappers/SearchToPresentationMapper.kt index 64891f2..0d1406c 100644 --- a/feature/search/src/main/java/com/chan/search/ui/mappers/SearchToPresentationMapper.kt +++ b/feature/search/src/main/java/com/chan/search/ui/mappers/SearchToPresentationMapper.kt @@ -2,11 +2,13 @@ package com.chan.search.ui.mappers import com.chan.android.model.ProductModel import com.chan.domain.ProductVO +import com.chan.search.domain.model.FilterCategoriesVO import com.chan.search.domain.model.SearchHistoryVO import com.chan.search.domain.model.TrendingSearchVO import com.chan.search.ui.model.SearchHistoryModel import com.chan.search.ui.model.SearchResultModel import com.chan.search.ui.model.TrendingSearchModel +import com.chan.search.ui.model.filter.FilterCategoriesModel import java.text.NumberFormat import java.util.Locale diff --git a/feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChip.kt b/feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChipModel.kt similarity index 92% rename from feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChip.kt rename to feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChipModel.kt index d5f1f30..f50416c 100644 --- a/feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChip.kt +++ b/feature/search/src/main/java/com/chan/search/ui/model/SearchResultFilterChipModel.kt @@ -3,7 +3,7 @@ package com.chan.search.ui.model import androidx.compose.runtime.Immutable @Immutable -data class SearchResultFilterChip( +data class SearchResultFilterChipModel( val image: String = "https://search.pstatic.net/common/?src=http%3A%2F%2Fblogfiles.naver.net%2FMjAyMTA4MzFfMjk0%2FMDAxNjMwMzY0NDkyODA3.Nk03J6zqVlXO3WZ1d5VtVikVbhdWkZwFu4nXHILBmj0g.aNfbPHZFvpSeaDNdrN__Lw2E29VR6IxhbhVgoT0caqkg.PNG.kma450815%2F21101_24218_4218.png&type=sc960_832", val text: String, val chipType: FilterChipType, diff --git a/feature/search/src/main/java/com/chan/search/ui/model/filter/DeliveryOption.kt b/feature/search/src/main/java/com/chan/search/ui/model/filter/DeliveryOption.kt new file mode 100644 index 0000000..1a63aa5 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/model/filter/DeliveryOption.kt @@ -0,0 +1,6 @@ +package com.chan.search.ui.model.filter + +enum class DeliveryOption { + TODAY_DELIVERY, + PICKUP +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/chan/search/ui/model/filter/FilterCategoriesModel.kt b/feature/search/src/main/java/com/chan/search/ui/model/filter/FilterCategoriesModel.kt new file mode 100644 index 0000000..f8d48c7 --- /dev/null +++ b/feature/search/src/main/java/com/chan/search/ui/model/filter/FilterCategoriesModel.kt @@ -0,0 +1,13 @@ +package com.chan.search.ui.model.filter + +data class FilterCategoriesModel( + val categoryId: String, + val name: String, + val subCategories: List +) { + + data class SubCategoryUiModel( + val subCategoryId: String, + val subCategoryName: String, + ) +} diff --git a/feature/search/src/main/java/com/chan/search/ui/viewmodel/SearchViewModel.kt b/feature/search/src/main/java/com/chan/search/ui/viewmodel/SearchViewModel.kt index 04586c0..b09607e 100644 --- a/feature/search/src/main/java/com/chan/search/ui/viewmodel/SearchViewModel.kt +++ b/feature/search/src/main/java/com/chan/search/ui/viewmodel/SearchViewModel.kt @@ -11,7 +11,11 @@ import com.chan.search.ui.mappers.toProductsModel import com.chan.search.ui.mappers.toSearchHistoryModel import com.chan.search.ui.mappers.toSearchModel import com.chan.search.ui.mappers.toTrendingSearchModel +import com.chan.search.ui.mappers.toUiModel +import com.chan.search.ui.model.FilterChipType +import com.chan.search.ui.model.SearchResultFilterChipModel import com.chan.search.ui.model.TrendingSearchModel +import com.chan.search.ui.model.filter.DeliveryOption import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce @@ -35,6 +39,9 @@ class SearchViewModel @Inject constructor( getRecommendedKeywords() getTrendingSearches() setCurrentTime() + initializeFilterChips() + initializeCategoryFilters() + updateFilteredProductCount() // 초기 개수 업데이트 } override fun setInitialState() = SearchContract.State() @@ -71,7 +78,7 @@ class SearchViewModel @Inject constructor( ) } - is SearchContract.Event.OnClickSearchResult -> { + is SearchContract.Event.OnClickSearchProduct -> { //클릭 시, 검색어에 맞는 리스트 보여주기 addSearchKeyword(event.clickedProductName) setState { copy(showSearchResult = true) } @@ -88,7 +95,7 @@ class SearchViewModel @Inject constructor( is SearchContract.Event.OnAddSearchKeyword -> { addSearchKeyword(event.search) getSearchResultProducts(event.search) - setState { copy(showSearchResult = true) } + setState { copy(showSearchResult = true, search = event.search) } } is SearchContract.Event.OnRemoveSearchKeyword -> removeSearchKeyword( @@ -96,15 +103,179 @@ class SearchViewModel @Inject constructor( ) SearchContract.Event.OnClearAllRecentSearches -> clearAllSearchKeyword() + SearchContract.Event.OnSearchTextFocus -> setState { copy(showSearchResult = false) } + SearchContract.Event.OnUpdateFilterClick -> setState { copy(showFilter = !showFilter) } + SearchContract.Event.OnFilterClear -> handleFilterClear() + + is SearchContract.Event.OnFilterChipClicked -> handleFilterChipClick(event.chip) + is SearchContract.Event.OnDeliveryOptionChanged -> handleDeliveryOptionChange(event.option) + is SearchContract.Event.OnCategoryHeaderClick -> handleCategoryHeaderClick(event.categoryName) + is SearchContract.Event.OnSubCategoryClick -> handleSubCategoryClick(event.subCategoryName) + SearchContract.Event.OnFilterCategoryClick -> setState { copy(isCategorySectionExpanded = !isCategorySectionExpanded) } + } + } + + private fun handleFilterClear() { + setState { + copy( + selectedDeliveryOption = null, + expandedCategoryName = null, + selectedSubCategories = emptySet(), + isCategorySectionExpanded = false, + filterChips = this.filterChips.map { it.copy(isSelected = false) } + ) + } + // 현재 검색어로 검색 결과를 다시 조회 + getSearchResultProducts(viewState.value.search) + } + + private fun handleSubCategoryClick(subCategoryName: String) { + val currentSelected = viewState.value.selectedSubCategories + val newSelected = if (currentSelected.contains(subCategoryName)) { + currentSelected - subCategoryName + } else { + currentSelected + subCategoryName + } + setState { copy(selectedSubCategories = newSelected) } + updateFilteredProductList() + } + + private fun updateFilteredProductCount() { + viewModelScope.launch { + val count = searchRepository.getFilteredProductCount(viewState.value.selectedSubCategories) + setState { copy(filteredProductCount = count) } + } + } + + private fun updateFilteredProductList() { + viewModelScope.launch { + val filteredProducts = searchRepository.getFilteredProducts(viewState.value.selectedSubCategories) + .map { it.toProductsModel() } + setState { + copy( + searchResultProducts = filteredProducts, + filteredProductCount = filteredProducts.size // 개수도 함께 업데이트 + ) + } + } + } + + private fun handleCategoryHeaderClick(categoryName: String) { + setState { + if (this.expandedCategoryName == categoryName) { + copy(expandedCategoryName = null) + } else { + copy(expandedCategoryName = categoryName) + } + } + } + + private fun handleDeliveryOptionChange(option: DeliveryOption) { + val currentSelectedOption = viewState.value.selectedDeliveryOption + val newSelectedOption = if (currentSelectedOption == option) { + null + } else { + option + } + + setState { + copy( + selectedDeliveryOption = newSelectedOption, + filterChips = this.filterChips.map { chip -> + if (chip.chipType != FilterChipType.TOGGLE) { + chip + } else { + val chipCorrespondsToNewSelection = + (chip.text == "오늘드림" && newSelectedOption == DeliveryOption.TODAY_DELIVERY) || + (chip.text == "픽업" && newSelectedOption == DeliveryOption.PICKUP) + + chip.copy(isSelected = chipCorrespondsToNewSelection) + } + } + ) + } + } + + private fun handleFilterChipClick(clickedChip: SearchResultFilterChipModel) { + val isClickedChipAlreadySelected = viewState.value.filterChips + .find { it.text == clickedChip.text } + ?.isSelected == true + + val newSelectedOption = if (!isClickedChipAlreadySelected) { + when (clickedChip.text) { + "오늘드림" -> DeliveryOption.TODAY_DELIVERY + "픽업" -> DeliveryOption.PICKUP + else -> viewState.value.selectedDeliveryOption + } + } else { + null + } + + setState { + copy( + selectedDeliveryOption = newSelectedOption, + filterChips = this.filterChips.map { chip -> + if (chip.chipType != FilterChipType.TOGGLE) { + chip + } else { + if (chip.text == clickedChip.text) { + chip.copy(isSelected = !isClickedChipAlreadySelected) + } else { + chip.copy(isSelected = false) + } + } + } + ) } } + private fun initializeCategoryFilters() { + viewModelScope.launch { + val categories = searchRepository.getFilterCategories() + .map { it.toUiModel() } + setState { copy(categoryFilters = categories) } + } + } + + private fun initializeFilterChips() { + val filterChips = listOf( + SearchResultFilterChipModel( + text = "오늘드림", + chipType = FilterChipType.TOGGLE + ), + SearchResultFilterChipModel( + text = "픽업", + chipType = FilterChipType.TOGGLE + ), + SearchResultFilterChipModel( + text = "카테고리", + chipType = FilterChipType.DROP_DOWN + ), + SearchResultFilterChipModel( + text = "주요기능", + chipType = FilterChipType.DROP_DOWN + ), + SearchResultFilterChipModel( + text = "가격", + chipType = FilterChipType.DROP_DOWN + ) + + ) + setState { copy(filterChips = filterChips) } + } + + private fun getSearchResultProducts(search: String) { handleRepositoryCall( call = { searchRepository.getSearchResultProducts(search).map { it.toProductsModel() } }, - onSuccess = { searchResultProducts -> copy(searchResultProducts = searchResultProducts) } + onSuccess = { searchResultProducts -> + copy( + searchResultProducts = searchResultProducts, + filteredProductCount = searchResultProducts.size // 개수도 함께 업데이트 + ) + } ) } diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 373b95a..7258328 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -7,4 +7,13 @@ 추천 키워드 급상승 검색어 NEW + 검색하신 상품이 없습니다. + + 오늘드림 + 픽업 + 카테고리 + 가격 + + 필터 + 상품 보기 방식 \ No newline at end of file