From 6c1f3eeb495d509136156fcefa7197e153adea54 Mon Sep 17 00:00:00 2001 From: kwakjoohyeong Date: Wed, 1 Jan 2025 23:46:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/mapper/SearchKeywordMapper.kt | 2 +- .../data/paging/PolicySearchPagingSource.kt | 22 +- .../DefaultYouthPolicyRepository.kt | 5 +- .../core/database/SearchKeywordDao.kt | 6 +- .../core/database/SearchKeywordEntity.kt | 3 +- .../core/domain/model/error/ClientError.kt | 6 +- .../repository/YouthPolicyRepository.kt | 3 +- .../domain/usecase/ClearAllKeywordsUseCase.kt | 2 +- .../core/domain/usecase/SearchUseCase.kt | 7 +- .../withpeace/feature/search/SearchScreen.kt | 246 +++++++++++++----- .../withpeace/feature/search/SearchUiEvent.kt | 1 + .../withpeace/feature/search/SearchUiState.kt | 9 +- .../feature/search/SearchViewModel.kt | 74 ++++-- .../src/main/res/drawable/ic_caution.xml | 17 ++ 14 files changed, 289 insertions(+), 114 deletions(-) create mode 100644 feature/search/src/main/res/drawable/ic_caution.xml diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt index 45054130..453c244f 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt @@ -4,5 +4,5 @@ import com.withpeace.withpeace.core.database.SearchKeywordEntity import com.withpeace.withpeace.core.domain.model.search.SearchKeyword fun SearchKeywordEntity.toDomain(): SearchKeyword { - return SearchKeyword(keyword ?: "") + return SearchKeyword(keyword) } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt index fb1fb398..3ab6da1a 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt @@ -1,11 +1,11 @@ package com.withpeace.withpeace.core.data.paging -import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState import com.skydoves.sandwich.ApiResponse import com.withpeace.withpeace.core.data.mapper.youthpolicy.toDomain import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.error.NoSearchResultException import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy import com.withpeace.withpeace.core.domain.repository.UserRepository import com.withpeace.withpeace.core.network.di.service.YouthPolicyService @@ -16,8 +16,9 @@ class PolicySearchPagingSource( private val keyword: String, private val onError: suspend (CheonghaError) -> Unit, private val userRepository: UserRepository, -) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { + private val onReceiveTotalCount: suspend (Int) -> Unit, +) : PagingSource>() { + override suspend fun load(params: LoadParams): LoadResult> { val pageIndex = params.key ?: 1 val response = youthPolicyService.search( keyword = keyword, @@ -27,19 +28,26 @@ class PolicySearchPagingSource( if (response is ApiResponse.Success) { val successResponse = (response).data + onReceiveTotalCount(successResponse.data.totalCount) + if (response.data.data.totalCount == 0) { + return LoadResult.Error(NoSearchResultException()) + } return LoadResult.Page( - data = successResponse.data.policies.map { it.toDomain() }, + data = successResponse.data.policies.map { + Pair( + successResponse.data.totalCount, + it.toDomain(), + ) + }, prevKey = if (pageIndex == STARTING_PAGE_INDEX) null else pageIndex - 1, nextKey = if (successResponse.data.policies.isEmpty()) null else pageIndex + (params.loadSize / pageSize), ) } else { - // 방법1 Error exception 으로 구분 - // 방법2 exception을 하단에서 방출 return LoadResult.Error(IllegalStateException("api state error")) } } - override fun getRefreshKey(state: PagingState): Int? { // 현재 포지션에서 Refresh pageKey 설정 + override fun getRefreshKey(state: PagingState>): Int? { // 현재 포지션에서 Refresh pageKey 설정 return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt index daece7a6..ad625e6e 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt @@ -1,5 +1,6 @@ package com.withpeace.withpeace.core.data.repository +import android.util.Log import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData @@ -107,7 +108,8 @@ class DefaultYouthPolicyRepository @Inject constructor( override fun search( searchKeyword: SearchKeyword, onError: suspend (CheonghaError) -> Unit, - ): Flow> { + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> { return Pager( config = PagingConfig(PAGE_SIZE), pagingSourceFactory = { @@ -117,6 +119,7 @@ class DefaultYouthPolicyRepository @Inject constructor( onError = onError, userRepository = userRepository, pageSize = PAGE_SIZE, + onReceiveTotalCount = onReceiveTotalCount ) }, ).flow diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt index 1aea19ad..5aed437b 100644 --- a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt @@ -2,7 +2,6 @@ package com.withpeace.withpeace.core.database import androidx.room.Dao import androidx.room.Delete -import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -13,15 +12,12 @@ interface SearchKeywordDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertKeyword(keyword: SearchKeywordEntity) - @Query("SELECT * FROM recent_search_keywords ORDER BY timestamp DESC") + @Query("SELECT * FROM recent_search_keywords ORDER BY timestamp DESC LIMIT 8") suspend fun getAllKeywords(): List @Delete suspend fun deleteKeyword(keyword: SearchKeywordEntity) - @Query("DELETE FROM recent_search_keywords WHERE id = :id") - suspend fun deleteKeywordById(id: Int) - @Query("DELETE FROM recent_search_keywords WHERE keyword = :keyword") suspend fun deleteKeywordByValue(keyword: String) diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt index 00c6181b..cc32b7d3 100644 --- a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt @@ -5,7 +5,6 @@ import androidx.room.PrimaryKey @Entity(tableName = "recent_search_keywords") data class SearchKeywordEntity( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val keyword: String, + @PrimaryKey(autoGenerate = false) val keyword: String, val timestamp: Long = System.currentTimeMillis(), // 저장 시각 ) \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt index 53a2245d..d35042bb 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt @@ -13,4 +13,8 @@ sealed interface ClientError : CheonghaError { data object NoSearchResult : SearchError data object SingleCharacterSearch : SearchError } -} \ No newline at end of file +} + +class NoSearchResultException: IllegalStateException() +class SingleCharacterSearchException: IllegalStateException() + diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt index b4de2c1f..53d03498 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt @@ -46,5 +46,6 @@ interface YouthPolicyRepository { fun search( searchKeyword: SearchKeyword, onError: suspend (CheonghaError) -> Unit, - ): Flow> + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> } \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt index 22fb156f..9a7f1d74 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt @@ -6,7 +6,7 @@ import javax.inject.Inject class ClearAllKeywordsUseCase @Inject constructor( private val repository: RecentSearchKeywordRepository ) { - suspend fun invoke() { + suspend operator fun invoke() { repository.clearAllKeywords() } } \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt index 4032e30a..0e449917 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt @@ -3,6 +3,7 @@ package com.withpeace.withpeace.core.domain.usecase import androidx.paging.PagingData import com.withpeace.withpeace.core.domain.model.error.CheonghaError import com.withpeace.withpeace.core.domain.model.error.ClientError +import com.withpeace.withpeace.core.domain.model.error.SingleCharacterSearchException import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy import com.withpeace.withpeace.core.domain.model.search.SearchKeyword import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository @@ -16,13 +17,15 @@ class SearchUseCase @Inject constructor( operator fun invoke( onError: suspend (CheonghaError) -> Unit, keyword: String, - ): Flow> { + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> { if (SearchKeyword.validate(keyword).not()) { - return flow { onError(ClientError.SearchError.SingleCharacterSearch) } + throw SingleCharacterSearchException() } return youthPolicyRepository.search( searchKeyword = SearchKeyword(keyword), onError = onError, + onReceiveTotalCount ) } } \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt index 1d0e4ef9..e8c3aeb8 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt @@ -22,24 +22,31 @@ 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.material3.BasicAlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf 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.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -49,9 +56,11 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.withpeace.withpeace.core.designsystem.R import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.domain.model.error.NoSearchResultException import com.withpeace.withpeace.core.ui.policy.YouthPolicyCard import com.withpeace.withpeace.core.ui.policy.YouthPolicyUiModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchRoute( onShowSnackBar: (message: String) -> Unit, @@ -60,8 +69,12 @@ fun SearchRoute( onBackButtonClick: () -> Unit, onPolicyClick: (String) -> Unit, ) { + val recentSearchKeywords = viewModel.recentSearchKeywords.collectAsStateWithLifecycle() val searchKeyword = viewModel.searchKeyword.collectAsStateWithLifecycle() val youthPolicies = viewModel.youthPolicyPagingFlow.collectAsLazyPagingItems() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + val dialogState = remember { mutableStateOf(false) } + val totalItemCount = viewModel.totalCountFlow.collectAsStateWithLifecycle() LaunchedEffect(key1 = viewModel.uiEvent) { viewModel.uiEvent.collect { @@ -77,19 +90,69 @@ fun SearchRoute( SearchUiEvent.UnBookmarkSuccess -> { onShowSnackBar("관심목록에서 삭제되었습니다.") } + + SearchUiEvent.SingleCharacteristicError -> { + dialogState.value = true + } + } + } + } + if (dialogState.value) { + BasicAlertDialog( + modifier = Modifier + .clip( + RoundedCornerShape(10.dp), + ) + .background(color = WithpeaceTheme.colors.SystemWhite) + .padding(vertical = 24.dp), + onDismissRequest = { + dialogState.value = false + }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "두 글자 이상 입력해주세요.", + style = WithpeaceTheme.typography.title2, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip( + RoundedCornerShape(10.dp), + ) + .background(color = WithpeaceTheme.colors.MainPurple), + onClick = { + dialogState.value = false + }, + ) { + Text( + textAlign = TextAlign.Center, + text = "확인", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemWhite, + ) + } } } } SearchScreen( onKeywordTextChanged = viewModel::onChangedKeyword, searchKeyword = searchKeyword.value, + uiState = uiState.value, + recentSearchKeyword = recentSearchKeywords.value, onBackButtonClick = onBackButtonClick, - onClickSearchKeyword = {}, - onRemoveRecentKeyword = {}, + onClickSearchKeyword = viewModel::onClickSearchKeyword, + onRemoveRecentKeyword = viewModel::removeKeywords, onRecentKeywordClick = viewModel::search, youthPolicies = youthPolicies, onBookmarkClick = viewModel::bookmark, onPolicyClick = onPolicyClick, + totalItemCount = totalItemCount.value, ) } @@ -97,7 +160,10 @@ fun SearchRoute( private fun SearchScreen( modifier: Modifier = Modifier, searchKeyword: String, - youthPolicies: LazyPagingItems, //TODO 이친구 연결해야 제대로 동작하는 듯 + totalItemCount: Int, + youthPolicies: LazyPagingItems, + recentSearchKeyword: List, + uiState: SearchUiState, onBackButtonClick: () -> Unit, onKeywordTextChanged: (String) -> Unit, onClickSearchKeyword: (String) -> Unit, @@ -106,7 +172,6 @@ private fun SearchScreen( onPolicyClick: (String) -> Unit, onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, ) { - val rememberKeyword = remember { mutableStateOf("") } val interactionSource = remember { MutableInteractionSource() } Column( modifier = modifier @@ -144,79 +209,131 @@ private fun SearchScreen( thickness = 1.dp, color = WithpeaceTheme.colors.SystemGray3, ) - SearchCompleted( - youthPolicies = youthPolicies, - onPolicyClick = onPolicyClick, - onBookmarkClick = onBookmarkClick, - ) - // Column( TODO 에러 뷰 - // modifier = modifier - // .fillMaxSize() - // .background(color = Color(0xFFF8F9FB)), - // ) { - // - // } - // SearchIntro( - // onClickSearchKeyword = onClickSearchKeyword, - // onRemoveKeyword = onRemoveKeyword, - // ) + when (uiState) { + is SearchUiState.PagingData -> { + SearchCompleted( + youthPolicies = youthPolicies, + searchKeyword = searchKeyword, + onPolicyClick = onPolicyClick, + onBookmarkClick = onBookmarkClick, + totalItemCount = totalItemCount, + ) + } + + else -> { + SearchIntro( + recentSearchKeyword = recentSearchKeyword, + onClickSearchKeyword = onClickSearchKeyword, + onRemoveKeyword = onRemoveRecentKeyword, + ) + } + } } } @Composable private fun SearchCompleted( modifier: Modifier = Modifier, + totalItemCount: Int, + searchKeyword: String, youthPolicies: LazyPagingItems, onPolicyClick: (String) -> Unit, onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, ) { + Column( modifier = modifier .fillMaxSize() .background(color = Color(0xFFF8F9FB)), ) { - LazyColumn( - modifier = modifier - .fillMaxSize() - .testTag("home:policies"), - contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 16.dp), - ) { - item { - Spacer(modifier = modifier.height(16.dp)) - Text("총 4개", style = WithpeaceTheme.typography.caption, color = WithpeaceTheme.colors.SystemGray1) - Spacer(modifier = modifier.height(16.dp)) + if (youthPolicies.loadState.refresh is LoadState.Error) { + if ((youthPolicies.loadState.refresh as LoadState.Error).error is NoSearchResultException) { + Column( + modifier = modifier + .padding(horizontal = 24.dp) + .fillMaxSize() + .background(color = Color(0xFFF8F9FB)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = modifier.height(76.dp)) + Image( + painter = painterResource(com.withpeace.withpeace.feature.search.R.drawable.ic_caution), + contentDescription = "결과 없음", + modifier = modifier.size(52.dp), + ) + Spacer(modifier = modifier.height(16.dp)) + Text( + text = buildAnnotatedString { + append("“") + withStyle( + style = SpanStyle( + color = WithpeaceTheme.colors.MainPurple, + ), + ) { // 키워드의 색상 설정 + append(searchKeyword) + } + append("”에 해당하는 검색 결과를 찾지 못했어요") + }, + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SystemGray1, + ) + Spacer(modifier.height(8.dp)) + Text( + "다른 검색어를 입력해보세요", + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SystemGray1, + ) + } } - items( - count = youthPolicies.itemCount, - key = youthPolicies.itemKey { it.id }, + } else { + LazyColumn( + modifier = modifier + .fillMaxSize() + .testTag("home:policies"), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 16.dp), ) { - val youthPolicy = youthPolicies[it] ?: throw IllegalStateException() - Spacer(modifier = modifier.height(8.dp)) - YouthPolicyCard( - modifier = modifier, - youthPolicy = youthPolicy, - onPolicyClick = onPolicyClick, - onBookmarkClick = onBookmarkClick, - ) - } - item { - if (youthPolicies.loadState.append is LoadState.Loading) { - Column( - modifier = modifier - .padding(top = 8.dp) - .fillMaxWidth() - .background( - Color.Transparent, - ), - ) { - CircularProgressIndicator( - modifier.align(Alignment.CenterHorizontally), - color = WithpeaceTheme.colors.MainPurple, - ) + item { + Spacer(modifier = modifier.height(16.dp)) + Text( + "총 ${totalItemCount}개", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemGray1, + ) + Spacer(modifier = modifier.height(16.dp)) + } + items( + count = youthPolicies.itemCount, + key = youthPolicies.itemKey { it.id }, + ) { + val youthPolicy = youthPolicies[it] ?: throw IllegalStateException() + Spacer(modifier = modifier.height(8.dp)) + YouthPolicyCard( + modifier = modifier, + youthPolicy = youthPolicy, + onPolicyClick = onPolicyClick, + onBookmarkClick = onBookmarkClick, + ) + } + item { + if (youthPolicies.loadState.append is LoadState.Loading) { + Column( + modifier = modifier + .padding(top = 8.dp) + .fillMaxWidth() + .background( + Color.Transparent, + ), + ) { + CircularProgressIndicator( + modifier.align(Alignment.CenterHorizontally), + color = WithpeaceTheme.colors.MainPurple, + ) + } } } } } + } } @@ -229,6 +346,7 @@ private fun SearchComponent( onSearchKeywordChanged: (String) -> Unit, onSearchClick: () -> Unit, ) { + val keyboardController = LocalSoftwareKeyboardController.current Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -260,6 +378,7 @@ private fun SearchComponent( keyboardActions = KeyboardActions( onSearch = { onSearchClick() + keyboardController?.hide() }, ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), @@ -297,6 +416,7 @@ private fun SearchComponent( @Composable private fun SearchIntro( modifier: Modifier = Modifier, + recentSearchKeyword: List, onClickSearchKeyword: (String) -> Unit, onRemoveKeyword: () -> Unit, ) { @@ -326,22 +446,24 @@ private fun SearchIntro( FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - List(5) { + recentSearchKeyword.map { Text( modifier = modifier .background( color = WithpeaceTheme.colors.SubPurple, shape = RoundedCornerShape(999.dp), ) - .padding(horizontal = 8.dp, vertical = 6.dp) .clickable { - onClickSearchKeyword("") - }, + onClickSearchKeyword(it) + } + .padding(horizontal = 8.dp, vertical = 6.dp), style = WithpeaceTheme.typography.caption, color = WithpeaceTheme.colors.MainPurple, - text = "여행$it", + text = it, ) } } } } + +//TODO 문구, 다이얼로그, 데이터 갯수 \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt index 6bd5fe86..05793528 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt @@ -3,5 +3,6 @@ package com.withpeace.withpeace.feature.search sealed interface SearchUiEvent { data object BookmarkFailure : SearchUiEvent data object BookmarkSuccess : SearchUiEvent + data object SingleCharacteristicError: SearchUiEvent data object UnBookmarkSuccess : SearchUiEvent } \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt index 0e6b0878..8e02718a 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt @@ -1,14 +1,7 @@ package com.withpeace.withpeace.feature.search -import androidx.paging.PagingData -import com.withpeace.withpeace.core.ui.policy.YouthPolicyUiModel - sealed interface SearchUiState { - data class SearchSuccess( - val data: PagingData - ) : SearchUiState data object Initialized : SearchUiState - data object NoSearchResult : SearchUiState - data object Loading : SearchUiState + data object PagingData : SearchUiState data object SearchFailure : SearchUiState } \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt index fde3ab57..67f9a5f9 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt @@ -7,11 +7,12 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import com.withpeace.withpeace.core.domain.extension.groupBy -import com.withpeace.withpeace.core.domain.model.error.ClientError +import com.withpeace.withpeace.core.domain.model.error.SingleCharacterSearchException import com.withpeace.withpeace.core.domain.model.policy.BookmarkInfo import com.withpeace.withpeace.core.domain.model.search.SearchKeyword import com.withpeace.withpeace.core.domain.usecase.AddKeywordUseCase import com.withpeace.withpeace.core.domain.usecase.BookmarkPolicyUseCase +import com.withpeace.withpeace.core.domain.usecase.ClearAllKeywordsUseCase import com.withpeace.withpeace.core.domain.usecase.GetAllKeywordsUseCase import com.withpeace.withpeace.core.domain.usecase.SearchUseCase import com.withpeace.withpeace.core.ui.policy.YouthPolicyUiModel @@ -23,14 +24,12 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update @@ -44,11 +43,16 @@ class SearchViewModel @Inject constructor( private val addKeywordUseCase: AddKeywordUseCase, private val getAllKeywordsUseCase: GetAllKeywordsUseCase, private val bookmarkPolicyUseCase: BookmarkPolicyUseCase, + private val clearAllKeywordsUseCase: ClearAllKeywordsUseCase, ) : ViewModel() { private val _uiEvent: Channel = Channel() val uiEvent = _uiEvent.receiveAsFlow() + private val _uiState: MutableStateFlow = + MutableStateFlow(SearchUiState.Initialized) + val uiState = _uiState.asStateFlow() + private val bookmarkStateFlow = MutableStateFlow(mapOf()) @@ -73,9 +77,15 @@ class SearchViewModel @Inject constructor( private val _searchKeyword = MutableStateFlow("") val searchKeyword = _searchKeyword.asStateFlow() + private val _recentSearchKeywords = MutableStateFlow>(listOf()) + val recentSearchKeywords = _recentSearchKeywords.asStateFlow() + + private val _totalCountFlow = MutableStateFlow(0) + val totalCountFlow = _totalCountFlow.asStateFlow() + init { viewModelScope.launch { - getAllKeywordsUseCase() + _recentSearchKeywords.value = getAllKeywordsUseCase().map { it.value } debounceFlow.groupBy { it.id }.flatMapMerge { it.second.debounce(300L) }.collectLatest { bookmarkInfo -> // policyBookmarkViewModel과 다른 이유를 찾아보기 @@ -108,26 +118,34 @@ class SearchViewModel @Inject constructor( fun search() { viewModelScope.launch { - _youthPolicyPagingFlow.update { - searchUseCase( - keyword = searchKeyword.value, - onError = { - when (it) { - is ClientError.SearchError.SingleCharacterSearch -> { - } - - is ClientError.SearchError.NoSearchResult -> { - } - - else -> { - } + runCatching { + _youthPolicyPagingFlow.update { + searchUseCase( + keyword = searchKeyword.value, + onError = { + }, + onReceiveTotalCount = { + Log.d("test",it.toString()) + _totalCountFlow.value = it + }, + ).onStart { + _uiState.update { + SearchUiState.PagingData } - }, - ).onStart { - Log.d("test", "testOnStart") - }.catch { }.map { data -> - data.map { it.toUiModel() } - }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty() + // 여기서 검색 state로 변경 + }.map { data -> + data.map { + it.second.toUiModel() + } + }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty() + } + }.onFailure { + when (it) { + is SingleCharacterSearchException -> { + _uiEvent.send(SearchUiEvent.SingleCharacteristicError) + } + } + return@launch } addKeywordUseCase(SearchKeyword(searchKeyword.value)) } @@ -139,4 +157,14 @@ class SearchViewModel @Inject constructor( debounceFlow.emit(BookmarkInfo(id, isChecked)) } } + + fun onClickSearchKeyword(keyword: String) { + this._searchKeyword.value = keyword + search() + } + + fun removeKeywords() { + viewModelScope.launch { clearAllKeywordsUseCase() } + _recentSearchKeywords.value = listOf() + } } diff --git a/feature/search/src/main/res/drawable/ic_caution.xml b/feature/search/src/main/res/drawable/ic_caution.xml new file mode 100644 index 00000000..bedcb46a --- /dev/null +++ b/feature/search/src/main/res/drawable/ic_caution.xml @@ -0,0 +1,17 @@ + + + + +