diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3d48890..4336c09f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,5 +67,6 @@ dependencies { implementation(projects.feature.course) implementation(projects.feature.collection) implementation(projects.feature.maps) + implementation(projects.feature.serach) implementation(libs.kakao.login) } diff --git a/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/card/SolplyPlaceCard.kt b/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/card/SolplyPlaceCard.kt index fe0e9bee..d9685a5e 100644 --- a/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/card/SolplyPlaceCard.kt +++ b/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/card/SolplyPlaceCard.kt @@ -55,11 +55,11 @@ fun SolplyPlaceCard( else -> SolplyTheme.colors.gray400 } val iconBackGroundColor = when (placeType) { - PlaceType.CAFE -> SolplyTheme.colors.red200 - PlaceType.FOOD -> SolplyTheme.colors.yellow100 - PlaceType.WALKING, PlaceType.UNIQUE_SPACE -> SolplyTheme.colors.green200 - PlaceType.SHOPPING, PlaceType.BOOKSTORE -> SolplyTheme.colors.purple100 - else -> SolplyTheme.colors.gray200 + PlaceType.CAFE -> SolplyTheme.colors.red500 + PlaceType.FOOD -> SolplyTheme.colors.yellow400 + PlaceType.WALKING, PlaceType.UNIQUE_SPACE -> SolplyTheme.colors.green500 + PlaceType.SHOPPING, PlaceType.BOOKSTORE -> SolplyTheme.colors.purple200 + else -> SolplyTheme.colors.purple500 } Column( diff --git a/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/searchbar/SolplySearchBar.kt b/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/searchbar/SolplySearchBar.kt new file mode 100644 index 00000000..889b8231 --- /dev/null +++ b/core/designsystem/src/main/java/com/teamsolply/solply/designsystem/component/searchbar/SolplySearchBar.kt @@ -0,0 +1,98 @@ +package com.teamsolply.solply.designsystem.component.searchbar + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.teamsolply.solply.designsystem.R +import com.teamsolply.solply.designsystem.theme.SolplyTheme + +@Composable +fun SolplySearchbar( + modifier: Modifier, + query: String, + onQueryChange: (String) -> Unit, + onImageClick: () -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + Row( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = SolplyTheme.colors.gray300, + shape = RoundedCornerShape(20.dp) + ) + .background(color = SolplyTheme.colors.white) + .padding(vertical = 14.dp, horizontal = 20.dp) + .clickable { + focusRequester.requestFocus() + keyboardController?.show() + }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .weight(1f) + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = SolplyTheme.typography.body16R.copy(color = SolplyTheme.colors.black), + decorationBox = { innerTextField -> + if (query.isEmpty()) { + Text( + text = "찾는 공간을 입력하세요", + style = SolplyTheme.typography.body16R, + color = SolplyTheme.colors.gray500 + ) + } + innerTextField() + }, + maxLines = 1, + singleLine = true + ) + Image( + painter = painterResource(id = R.drawable.ic_setting), // ic_search 134 브랜치 들어오고 바꿔야함 + contentDescription = "Search Icon", + modifier = Modifier + .clickable { + onImageClick() + keyboardController?.hide() + } + ) + } +} + +@Preview +@Composable +fun SearchBoxPreview() { + SolplyTheme { + SolplySearchbar( + modifier = Modifier.fillMaxWidth(), + query = "", + onQueryChange = {}, + onImageClick = {} + ) + } +} diff --git a/domain/search/.gitignore b/domain/search/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/domain/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/search/build.gradle.kts b/domain/search/build.gradle.kts new file mode 100644 index 00000000..7f643ef7 --- /dev/null +++ b/domain/search/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.solply.java.library) +} + +dependencies { + implementation(projects.core.model) + implementation(libs.bundles.coroutine) +} diff --git a/domain/search/src/main/java/com/teamsolply/solply/search/MyClass.kt b/domain/search/src/main/java/com/teamsolply/solply/search/MyClass.kt new file mode 100644 index 00000000..16ef3f8d --- /dev/null +++ b/domain/search/src/main/java/com/teamsolply/solply/search/MyClass.kt @@ -0,0 +1,3 @@ +package com.teamsolply.solply.search + +class MyClass diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 770530c7..db663db6 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(projects.feature.oauth) implementation(projects.feature.onboarding) implementation(projects.feature.place) + implementation(projects.feature.serach) implementation(projects.feature.course) implementation(projects.feature.maps) implementation(projects.feature.collection) diff --git a/feature/main/src/main/java/com/teamsolply/solply/main/MainActivity.kt b/feature/main/src/main/java/com/teamsolply/solply/main/MainActivity.kt index 88fc7ae2..14c31672 100644 --- a/feature/main/src/main/java/com/teamsolply/solply/main/MainActivity.kt +++ b/feature/main/src/main/java/com/teamsolply/solply/main/MainActivity.kt @@ -21,7 +21,8 @@ class MainActivity : ComponentActivity() { setContent { SolplyTheme { MainScreen( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + isFreshLaunch = savedInstanceState == null ) } } diff --git a/feature/main/src/main/java/com/teamsolply/solply/main/MainNavigator.kt b/feature/main/src/main/java/com/teamsolply/solply/main/MainNavigator.kt index cb47ab79..1cfaf853 100644 --- a/feature/main/src/main/java/com/teamsolply/solply/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/teamsolply/solply/main/MainNavigator.kt @@ -19,6 +19,7 @@ import com.teamsolply.solply.maps.navigation.navigateMaps import com.teamsolply.solply.oauth.navigation.navigateOauth import com.teamsolply.solply.onboarding.navigation.navigateOnBoarding import com.teamsolply.solply.place.navigation.navigatePlace +import com.teamsolply.solply.search.navigation.navigateSearch internal class MainNavigator( val navController: NavHostController @@ -85,6 +86,10 @@ internal class MainNavigator( navController.navigatePlace(navOptions) } + fun navigateToSearch(navOptions: NavOptions) { + navController.navigateSearch(navOptions) + } + fun navigateToCourse(navOptions: NavOptions) { navController.navigateCourse(navOptions) } diff --git a/feature/main/src/main/java/com/teamsolply/solply/main/MainScreen.kt b/feature/main/src/main/java/com/teamsolply/solply/main/MainScreen.kt index c6c8b092..1611c1b8 100644 --- a/feature/main/src/main/java/com/teamsolply/solply/main/MainScreen.kt +++ b/feature/main/src/main/java/com/teamsolply/solply/main/MainScreen.kt @@ -15,13 +15,16 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.teamsolply.solply.collection.collection.course.courseCollectionNavGraph @@ -38,24 +41,51 @@ import com.teamsolply.solply.main.component.MainBottomBar import com.teamsolply.solply.main.model.SolplySnackBarData import com.teamsolply.solply.main.splash.splashNavGraph import com.teamsolply.solply.maps.navigation.mapsNavGraph +import com.teamsolply.solply.model.MapsType import com.teamsolply.solply.model.SnackBarType import com.teamsolply.solply.oauth.navigation.oauthNavGraph import com.teamsolply.solply.onboarding.navigation.onBoardingNavGraph import com.teamsolply.solply.place.navigation.placeNavGraph +import com.teamsolply.solply.search.navigation.Search +import com.teamsolply.solply.search.navigation.searchNavGraph import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Composable internal fun MainScreen( modifier: Modifier = Modifier, - navigator: MainNavigator = rememberMainNavigator() + navigator: MainNavigator = rememberMainNavigator(), + isFreshLaunch: Boolean = false ) { val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val currentSnackbarJob = remember { mutableStateOf(null) } val currentSnackbarState = remember { mutableStateOf(SolplySnackBarData()) } + val navController = navigator.navController + + if (isFreshLaunch) { + LaunchedEffect(navController) { + val initialDestination = snapshotFlow { navController.currentBackStackEntry } + .filterNotNull() + .first() + .destination + + if (!initialDestination.hasRoute(Search::class)) { + val initialNavOptions = navOptions { + popUpTo(0) { + inclusive = true + } + launchSingleTop = true + } + navigator.navigateToSearch(initialNavOptions) + } + } + } + suspend fun showTextSnackBar(message: String) { currentSnackbarJob.value?.join() currentSnackbarJob.value = coroutineScope.launch { @@ -128,6 +158,29 @@ internal fun MainScreen( .background(color = SolplyTheme.colors.gray100) .fillMaxSize() ) { + searchNavGraph( + paddingValues = innerPadding, + onBack = navigator::navigateToBack, + navigateToPlaceDetail = { townId, placeId -> + val navOptions = navOptions {} + navigator.navigateToMaps( + mapsType = MapsType.PLACE_DETAIL.name, + townId = townId, + placeId = placeId, + courseId = null, + navOptions = navOptions + ) + }, + onNoPlaceClick = { + val navOptions = navOptions { + popUpTo(0) { + inclusive = true + } + launchSingleTop = true + } + navigator.navigateToPlace(navOptions = navOptions) + } + ) splashNavGraph( navigateToOauth = { val navOptions = navOptions { diff --git a/feature/maps/src/main/java/com/teamsolply/solply/maps/component/bottomsheet/PlaceDetailBottomSheet.kt b/feature/maps/src/main/java/com/teamsolply/solply/maps/component/bottomsheet/PlaceDetailBottomSheet.kt index d88cf977..b5e4c191 100644 --- a/feature/maps/src/main/java/com/teamsolply/solply/maps/component/bottomsheet/PlaceDetailBottomSheet.kt +++ b/feature/maps/src/main/java/com/teamsolply/solply/maps/component/bottomsheet/PlaceDetailBottomSheet.kt @@ -3,7 +3,6 @@ package com.teamsolply.solply.maps.component.bottomsheet import ClickableAnnotatedText import android.content.Context import android.content.Intent -import android.util.Log import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/feature/place/src/main/java/com/teamsolply/solply/place/PlaceScreen.kt b/feature/place/src/main/java/com/teamsolply/solply/place/PlaceScreen.kt index 9fb4ed13..a16946e7 100644 --- a/feature/place/src/main/java/com/teamsolply/solply/place/PlaceScreen.kt +++ b/feature/place/src/main/java/com/teamsolply/solply/place/PlaceScreen.kt @@ -1,6 +1,5 @@ package com.teamsolply.solply.place -import android.util.Log import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/feature/serach/.gitignore b/feature/serach/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/serach/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/serach/build.gradle.kts b/feature/serach/build.gradle.kts new file mode 100644 index 00000000..4a622c52 --- /dev/null +++ b/feature/serach/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.solply.feature) +} + +android { + namespace = "com.teamsolply.solply.search" +} + +dependencies { + implementation(projects.core.designsystem) + implementation(projects.core.model) + implementation(projects.core.ui) + implementation(projects.domain.place) + implementation(projects.domain.search) +} diff --git a/feature/serach/consumer-rules.pro b/feature/serach/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/serach/proguard-rules.pro b/feature/serach/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/serach/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/serach/src/main/AndroidManifest.xml b/feature/serach/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/serach/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/serach/src/main/java/com/teamsolply/solply/search/SearchContract.kt b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchContract.kt new file mode 100644 index 00000000..601f77b6 --- /dev/null +++ b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchContract.kt @@ -0,0 +1,40 @@ +package com.teamsolply.solply.search + +import com.teamsolply.solply.model.PlaceType +import com.teamsolply.solply.ui.base.SideEffect +import com.teamsolply.solply.ui.base.UiIntent +import com.teamsolply.solply.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +data class SearchState( + val query: String = "", + val isLoading: Boolean = false, + val results: PersistentList = persistentListOf(), + val selectedTownId: Long? = null +) : UiState { + val resultCount: Int get() = results.size + val hasQuery: Boolean get() = query.isNotBlank() +} + +data class SearchItemUi( + val id: Long, + val name: String, + val tag: PlaceType, + val address: String, + val imageUrl: String +) + +sealed interface SearchIntent : UiIntent { + data class QueryChanged(val value: String) : SearchIntent + data object ClearQuery : SearchIntent + data object Submit : SearchIntent + data class ClickItem(val id: Long) : SearchIntent + data object ClickNoResult : SearchIntent + data object Retry : SearchIntent +} + +sealed interface SearchSideEffect : SideEffect { + data class NavigateToPlaceDetail(val townId: Long, val placeId: Long) : SearchSideEffect + data object NavigateToNoResult : SearchSideEffect +} diff --git a/feature/serach/src/main/java/com/teamsolply/solply/search/SearchScreen.kt b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchScreen.kt new file mode 100644 index 00000000..bf6fe519 --- /dev/null +++ b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchScreen.kt @@ -0,0 +1,170 @@ +package com.teamsolply.solply.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.teamsolply.solply.designsystem.component.searchbar.SolplySearchbar +import com.teamsolply.solply.designsystem.component.topbar.SolplyTopBar +import com.teamsolply.solply.designsystem.theme.SolplyTheme +import com.teamsolply.solply.search.component.SearchItem +import com.teamsolply.solply.ui.extension.customClickable +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun SearchRoute( + paddingValues: PaddingValues, + onBack: () -> Unit, + navigateToPlaceDetail: (Long, Long) -> Unit, + onNoPlaceClick: () -> Unit, + viewModel: SearchViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { se -> + when (se) { + is SearchSideEffect.NavigateToPlaceDetail -> navigateToPlaceDetail(se.townId, se.placeId) + SearchSideEffect.NavigateToNoResult -> onNoPlaceClick() + } + } + } + LaunchedEffect(Unit) { viewModel.sendIntent(SearchIntent.Retry) } + + SearchScreen( + modifier = Modifier.padding(paddingValues), + state = state, + onBack = onBack, + onQueryChanged = { viewModel.sendIntent(SearchIntent.QueryChanged(it)) }, + onClearQuery = { viewModel.sendIntent(SearchIntent.ClearQuery) }, + onSubmit = { viewModel.sendIntent(SearchIntent.Submit) }, + onClickItem = { id -> viewModel.sendIntent(SearchIntent.ClickItem(id)) }, + onClickNoResult = { viewModel.sendIntent(SearchIntent.ClickNoResult) } + ) +} + +@Composable +fun SearchScreen( + modifier: Modifier = Modifier, + state: SearchState, + onBack: () -> Unit, + onQueryChanged: (String) -> Unit, + onClearQuery: () -> Unit, + onSubmit: () -> Unit, + onClickItem: (Long) -> Unit, + onClickNoResult: () -> Unit +) { + BackHandler(onBack = onBack) + + Column( + modifier = modifier + .fillMaxSize() + .padding(bottom = 12.dp) + ) { + SolplyTopBar( + barText = "검색하기", + onBackButtonClick = onBack + ) + + SolplySearchbar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + query = state.query, + onQueryChange = { + onQueryChanged(it) + if (it.isBlank()) { + onClearQuery() + } + }, + onImageClick = onSubmit + ) + + if (state.hasQuery) { + Text( + text = "검색 결과 ${state.resultCount}개", + style = SolplyTheme.typography.button14M, + color = SolplyTheme.colors.gray800, + modifier = Modifier.padding(start = 20.dp, top = 32.dp, bottom = 16.dp) + ) + } + + if (state.hasQuery) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + item { + HorizontalDivider( + thickness = 1.dp, + color = SolplyTheme.colors.gray200 + ) + } + + val items = state.results.toImmutableList() + itemsIndexed(items) { _, item -> + SearchItem( + placeName = item.name, + placeTag = item.tag, + placeAddress = item.address, + placeImageUrl = item.imageUrl, + onClick = { onClickItem(item.id) } + ) + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = SolplyTheme.colors.gray200 + ) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .customClickable(rippleEnabled = false) { onClickNoResult() } + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "찾는 장소가 없어요", + style = SolplyTheme.typography.button14M, + color = SolplyTheme.colors.gray700, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(id = com.teamsolply.solply.designsystem.R.drawable.ic_arrow_right_icon), + contentDescription = "arrow-right-icon", + tint = SolplyTheme.colors.gray700 + ) + } + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = SolplyTheme.colors.gray200 + ) + } + } + } + } +} diff --git a/feature/serach/src/main/java/com/teamsolply/solply/search/SearchViewModel.kt b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchViewModel.kt new file mode 100644 index 00000000..d29c0592 --- /dev/null +++ b/feature/serach/src/main/java/com/teamsolply/solply/search/SearchViewModel.kt @@ -0,0 +1,118 @@ +package com.teamsolply.solply.search + +import androidx.lifecycle.viewModelScope +import com.teamsolply.solply.model.PlaceType +import com.teamsolply.solply.place.repository.PlaceRepository +import com.teamsolply.solply.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: PlaceRepository +) : BaseViewModel(SearchState()) { + + private var typingJob: Job? = null + + init { + reduce { copy(query = "", results = emptyList().toPersistentList()) } + } + + override fun handleIntent(intent: SearchIntent) { + when (intent) { + is SearchIntent.QueryChanged -> { + reduce { copy(query = intent.value) } + typingJob?.cancel() + typingJob = viewModelScope.launch { + delay(250) + search(currentState.query) + } + } + SearchIntent.ClearQuery -> { + typingJob?.cancel() + reduce { + copy( + query = "", + isLoading = false, + results = emptyList().toPersistentList(), + selectedTownId = null + ) + } + } + SearchIntent.Submit -> { + viewModelScope.launch { search(currentState.query) } + } + is SearchIntent.ClickItem -> { + val townId = currentState.selectedTownId ?: return + postSideEffect( + SearchSideEffect.NavigateToPlaceDetail( + townId = townId, + placeId = intent.id + ) + ) + } + SearchIntent.ClickNoResult -> { + postSideEffect(SearchSideEffect.NavigateToNoResult) + } + SearchIntent.Retry -> { + // 필요 시 최근 검색어 복구/추천 키워드 로딩 등을 붙일 자리 + } + } + } + + private suspend fun search(query: String) { + if (query.isBlank()) { + reduce { copy(isLoading = false, results = emptyList().toPersistentList()) } + return + } + reduce { copy(isLoading = true) } + + val filtered = DUMMY_RESULTS.filter { item -> + val normalizedQuery = query.trim().lowercase() + item.name.lowercase().contains(normalizedQuery) || item.address.lowercase().contains(normalizedQuery) + } + + val townId = repository.getUserInfo() + .map { it.selectedTown.townId } + .getOrDefault(DEFAULT_TOWN_ID) + + reduce { + copy( + isLoading = false, + selectedTownId = townId, + results = filtered.toPersistentList() + ) + } + } + + private companion object { + const val DEFAULT_TOWN_ID = 0L + + val DUMMY_RESULTS = listOf( + SearchItemUi( + id = 1, + name = "솔플리솔플리", + tag = PlaceType.FOOD, + address = "주소주소주소주소", + imageUrl = "" + ), + SearchItemUi( + id = 2, + name = "솔플리솔플리", + tag = PlaceType.BOOKSTORE, + imageUrl = "" + ), + SearchItemUi( + id = 3, + name = "솔플리솔플리", + tag = PlaceType.UNIQUE_SPACE, + address = "주소주소주소주소주소주소주소주주소주소주소주소", + imageUrl = "" + ) + ) + } +} diff --git a/feature/serach/src/main/java/com/teamsolply/solply/search/component/SearchItem.kt b/feature/serach/src/main/java/com/teamsolply/solply/search/component/SearchItem.kt new file mode 100644 index 00000000..ca00247c --- /dev/null +++ b/feature/serach/src/main/java/com/teamsolply/solply/search/component/SearchItem.kt @@ -0,0 +1,117 @@ +package com.teamsolply.solply.search.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.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.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.teamsolply.solply.designsystem.component.chip.PlaceTag +import com.teamsolply.solply.designsystem.theme.SolplyTheme +import com.teamsolply.solply.model.PlaceType +import com.teamsolply.solply.ui.extension.customClickable +import com.teamsolply.solply.ui.image.AdaptationImage +import formatTextToPlaceItemTitle + +@Composable +internal fun SearchItem( + placeName: String, + placeTag: PlaceType, + placeAddress: String, + placeImageUrl: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxWidth() + .customClickable(rippleEnabled = false) { onClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + AdaptationImage( + imageUrl = placeImageUrl, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(SolplyTheme.colors.gray200), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + PlaceTag( + type = placeTag, + modifier = Modifier.padding(end = 4.dp) + ) + Text( + text = placeName.formatTextToPlaceItemTitle(), + color = SolplyTheme.colors.black, + style = SolplyTheme.typography.title15M, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = placeAddress, + color = SolplyTheme.colors.gray700, + style = SolplyTheme.typography.caption12R, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, widthDp = 360) +@Composable +private fun SearchItemPreview() { + SolplyTheme { + Column { + val shopping = PlaceType.entries.firstOrNull { it.name == "SHOPPING" } ?: PlaceType.ALL + val food = PlaceType.entries.firstOrNull { it.name == "FOOD" } ?: PlaceType.ALL + val walk = PlaceType.entries.firstOrNull { it.name == "WALK" } ?: PlaceType.ALL + + SearchItem( + placeName = "공간 이름", + placeTag = shopping, + placeAddress = "상세주소 한 줄만", + placeImageUrl = "https://picsum.photos/200/200", + onClick = {} + ) + SearchItem( + placeName = "공간 이름", + placeTag = food, + placeAddress = "상세주소 한 줄인데 넘어가면 어떻게 되는지 잘 잘리는지 ", + placeImageUrl = "https://picsum.photos/200/200", + onClick = {} + ) + } + } +} diff --git a/feature/serach/src/main/java/com/teamsolply/solply/search/navigation/SearchNavigation.kt b/feature/serach/src/main/java/com/teamsolply/solply/search/navigation/SearchNavigation.kt new file mode 100644 index 00000000..6e22b729 --- /dev/null +++ b/feature/serach/src/main/java/com/teamsolply/solply/search/navigation/SearchNavigation.kt @@ -0,0 +1,33 @@ +package com.teamsolply.solply.search.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.teamsolply.solply.navigation.Route +import com.teamsolply.solply.search.SearchRoute +import kotlinx.serialization.Serializable + +fun NavController.navigateSearch(navOptions: NavOptions) { + navigate(Search, navOptions) +} + +fun NavGraphBuilder.searchNavGraph( + paddingValues: PaddingValues, + onBack: () -> Unit, + navigateToPlaceDetail: (Long, Long) -> Unit, + onNoPlaceClick: () -> Unit +) { + composable { + SearchRoute( + paddingValues = paddingValues, + onBack = onBack, + navigateToPlaceDetail = navigateToPlaceDetail, + onNoPlaceClick = onNoPlaceClick + ) + } +} + +@Serializable +data object Search : Route diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a540fc8..f0057a98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,3 +63,5 @@ include(":feature:place") include(":feature:course") include(":feature:collection") include(":feature:maps") +include(":feature:serach") +include(":domain:search")