diff --git a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt index cd93ed6..f55f15f 100644 --- a/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/FestaBookAppGraph.kt @@ -8,7 +8,6 @@ import com.daedan.festabook.di.viewmodel.MetroViewModelFactory import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.main.MainActivity import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior import com.daedan.festabook.presentation.schedule.ScheduleViewModel import com.daedan.festabook.presentation.splash.SplashActivity import com.google.android.play.core.appupdate.AppUpdateManager @@ -34,8 +33,6 @@ interface FestaBookAppGraph { fun inject(activity: PlaceDetailActivity) - fun inject(placeListBottomSheetBehavior: PlaceListBottomSheetBehavior<*>) - // splashActivity @Provides fun provideAppUpdateManager(application: Application): AppUpdateManager = AppUpdateManagerFactory.create(application) diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt index cee1165..b487a17 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/FragmentUtil.kt @@ -8,12 +8,10 @@ import android.os.Parcelable import android.util.TypedValue import android.view.View import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.daedan.festabook.R import com.daedan.festabook.data.util.ApiResultException -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetFollowBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import java.io.Serializable @@ -128,8 +126,3 @@ fun Activity.showSnackBar(msg: String) { }.setActionTextColor(getColor(R.color.blue400)) snackBar.show() } - -fun View.placeListBottomSheetFollowBehavior(): PlaceListBottomSheetFollowBehavior? { - val params = layoutParams as? CoordinatorLayout.LayoutParams - return params?.behavior as? PlaceListBottomSheetFollowBehavior -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt index 734af70..3ccfab1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/CoilImage.kt @@ -12,6 +12,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.convertImageUrl @Composable fun CoilImage( @@ -26,7 +27,7 @@ fun CoilImage( ImageRequest .Builder(LocalContext.current) .apply(builder) - .data(url) + .data(url.convertImageUrl()) .crossfade(true) .build(), contentDescription = contentDescription, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index c20e13f..5eebe62 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -22,6 +22,7 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -67,6 +68,15 @@ class PlaceMapViewModel( private val _isExceededMaxLength: MutableLiveData = MutableLiveData() val isExceededMaxLength: LiveData = _isExceededMaxLength + val isExceededMaxLengthFlow: StateFlow = + _isExceededMaxLength + .asFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = false, + ) + private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked @@ -76,6 +86,10 @@ class PlaceMapViewModel( private val _onMapViewClick: MutableLiveData> = MutableLiveData() val onMapViewClick: LiveData> = _onMapViewClick + val onMapViewClickFlow: Flow> = + _onMapViewClick + .asFlow() + init { loadOrganizationGeography() loadTimeTags() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt new file mode 100644 index 0000000..e0315a7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryLabel.kt @@ -0,0 +1,79 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.getIconId +import com.daedan.festabook.presentation.placeMap.model.getLabelColor +import com.daedan.festabook.presentation.placeMap.model.getTextId +import com.daedan.festabook.presentation.theme.festabookShapes +import kotlin.math.roundToInt + +@Composable +fun PlaceCategoryLabel( + category: PlaceCategoryUiModel, + modifier: Modifier = Modifier, + iconColor: Color = category.getLabelColor(), +) { + Card( + shape = festabookShapes.radius1, + colors = + CardColors( + containerColor = getBackgroundColor(iconColor), + contentColor = Color.Unspecified, + disabledContainerColor = getBackgroundColor(iconColor), + disabledContentColor = Color.Unspecified, + ), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(category.getIconId()), + contentDescription = stringResource(category.getTextId()), + tint = iconColor, + modifier = Modifier.size(12.dp), + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = stringResource(category.getTextId()), + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +private fun getBackgroundColor(color: Color): Color { + // 10% 투명도를 가지게 변경 + val alpha = (MAX_ALPHA * ALPHA_RATIO).roundToInt() + return color.copy(alpha = alpha / MAX_ALPHA.toFloat()) +} + +private const val MAX_ALPHA = 255 +private const val ALPHA_RATIO = 0.10f + +@Preview(showBackground = true) +@Composable +private fun PlaceCategoryLabelPreview() { + val category = PlaceCategoryUiModel.FOOD_TRUCK + PlaceCategoryLabel( + category = category, + iconColor = Color(0xFF00AB40), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 3443c86..1d3d721 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt @@ -5,16 +5,41 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.NaverMap +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceMapScreen( + timeTagTitle: String, + timeTags: List, + places: List, + modifier: Modifier = Modifier, + onMapReady: (NaverMap) -> Unit = {}, + onPlaceClick: (PlaceUiModel) -> Unit = {}, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + PlaceMapContent( + title = timeTagTitle, + timeTags = timeTags, + onMapReady = onMapReady, + onTimeTagClick = onTimeTagClick, + ) +} + +@Composable +private fun PlaceMapContent( timeTags: List, title: String, onMapReady: (NaverMap) -> Unit, @@ -42,6 +67,35 @@ fun PlaceMapScreen( ).padding(horizontal = 24.dp), ) } + PlaceCategoryScreen() } } } + +@Preview(showBackground = true) +@Composable +private fun PlaceMapScreenPreview() { + FestabookTheme { + PlaceMapScreen( + timeTagTitle = "테스트", + timeTags = + listOf( + TimeTag(1, "테스트1"), + TimeTag(2, "테스트2"), + ), + places = + (0..100).map { + PlaceUiModel( + id = it.toLong(), + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.BAR, + isBookmarked = true, + timeTagId = listOf(1), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt index 5b63718..064b092 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceCategoryUiModel.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.model +import androidx.compose.ui.graphics.Color import com.daedan.festabook.R import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager @@ -65,6 +66,14 @@ val PlaceCategoryUiModel.Companion.iconResources: List R.drawable.ic_extra_selected, ) +fun PlaceCategoryUiModel.getLabelColor() = + when (this) { + PlaceCategoryUiModel.BOOTH -> Color(0xFF0094FF) + PlaceCategoryUiModel.FOOD_TRUCK -> Color(0xFF00AB40) + PlaceCategoryUiModel.BAR -> Color(0xFFFF9D00) + else -> Color.Unspecified + } + fun OverlayImageManager.getNormalIcon(category: PlaceCategoryUiModel): OverlayImage? = when (category) { PlaceCategoryUiModel.BOOTH -> getImage(R.drawable.ic_booth) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt index 9674004..561e744 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt @@ -14,6 +14,4 @@ sealed interface PlaceListUiState { data class Error( val throwable: Throwable, ) : PlaceListUiState - - class Complete : PlaceListUiState } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt deleted file mode 100644 index 6dd7cd4..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListBottomSheetCallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.view.View -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.google.android.material.bottomsheet.BottomSheetBehavior -import timber.log.Timber - -class PlaceListBottomSheetCallback( - private val viewModel: PlaceMapViewModel, -) : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - when (newState) { - BottomSheetBehavior.STATE_DRAGGING -> { - Timber.d("STATE_DRAGGING") - viewModel.onExpandedStateReached() - } - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) { - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index f44724c..779b277 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -2,26 +2,30 @@ package com.daedan.festabook.presentation.placeMap.placeList import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DefaultItemAnimator import coil3.ImageLoader import coil3.asImage import coil3.request.ImageRequest import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceListBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.placeListBottomSheetFollowBehavior import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel @@ -29,13 +33,11 @@ import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.adapter.PlaceListAdapter -import com.daedan.festabook.presentation.placeMap.placeList.behavior.BottomSheetFollowCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.MoveToInitialPositionCallback -import com.daedan.festabook.presentation.placeMap.placeList.behavior.PlaceListBottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetValue +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListScreen +import com.daedan.festabook.presentation.placeMap.placeList.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.NaverMap import com.naver.maps.map.OnMapReadyCallback import dev.zacsweers.metro.AppScope @@ -46,8 +48,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import timber.log.Timber @@ -64,36 +66,79 @@ class PlaceListFragment( private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) private val childViewModel: PlaceListViewModel by viewModels() - private val placeAdapter by lazy { - PlaceListAdapter(this) - } + // 기존 Fragment와의 상호 운용성을 위한 임시 Flow입니다. + // Fragment -> PlaceMapScreen으로 통합 시, 제거할 예정입니다. + private val mapFlow: MutableStateFlow = MutableStateFlow(null) - private val placeListBottomSheetBehavior by lazy { - val params = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - params?.behavior as? PlaceListBottomSheetBehavior - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + super.onCreateView(inflater, container, savedInstanceState) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val places by childViewModel.placesFlow.collectAsStateWithLifecycle() + val isExceedMaxLength by viewModel.isExceededMaxLengthFlow.collectAsStateWithLifecycle() + val bottomSheetState = rememberPlaceListBottomSheetState() + val map by mapFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.onMapViewClickFlow.collect { + if (isGone || !isResumed || view == null) return@collect + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } - private lateinit var moveToInitialPositionCallback: MoveToInitialPositionCallback + FestabookTheme { + PlaceListScreen( + placesUiState = places, + map = map, + onPlaceClick = { onPlaceClicked(it) }, + bottomSheetState = bottomSheetState, + isExceedMaxLength = isExceedMaxLength, + onPlaceLoadFinish = { places -> + preloadImages( + requireContext(), + places, + ) + }, + onPlaceLoad = { + viewModel.selectedTimeTagFlow.collect { + childViewModel.updatePlacesByTimeTag(it.timeTagId) + } + }, + onError = { + showErrorSnackBar(it.throwable) + }, + onBackToInitialPositionClick = { + viewModel.onBackToInitialPositionClicked() + appGraph.defaultFirebaseLogger.log( + PlaceBackToSchoolClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + ), + ) + }, + ) + } + } + } override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - setUpPlaceAdapter() - setBehaviorCallback() - setUpObserver() - setUpBinding() - } + setUpObserver() } override fun onPlaceClicked(place: PlaceUiModel) { Timber.d("onPlaceClicked: $place") startPlaceDetailActivity(place) - binding.logger.log( + appGraph.defaultFirebaseLogger.log( PlaceItemClick( - baseLogData = binding.logger.getBaseLogData(), + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), placeId = place.id, timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", category = place.category.name, @@ -103,63 +148,23 @@ class PlaceListFragment( override fun onMenuItemReClick() { if (binding.root.isGone || !isResumed || view == null) return - val layoutParams = binding.layoutPlaceList.layoutParams as? CoordinatorLayout.LayoutParams - val behavior = layoutParams?.behavior as? BottomSheetBehavior - behavior?.state = BottomSheetBehavior.STATE_HALF_EXPANDED - binding.logger.log( + lifecycleScope.launch { + viewModel.onMapViewClick() + } + appGraph.defaultFirebaseLogger.log( PlaceMapButtonReClick( - baseLogData = binding.logger.getBaseLogData(), + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), ), ) } override fun onMapReady(naverMap: NaverMap) { - binding.lbvCurrentLocation.map = naverMap - } - - private fun setUpPlaceAdapter() { - binding.rvPlaces.adapter = placeAdapter - (binding.rvPlaces.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false + lifecycleScope.launch { + mapFlow.value = naverMap + } } private fun setUpObserver() { - childViewModel.places.observe(viewLifecycleOwner) { places -> - when (places) { - is PlaceListUiState.Loading -> showSkeleton() - is PlaceListUiState.Success -> { - preloadImages( - requireContext(), - places.value, - ) - placeAdapter.submitList(places.value) { - if (places.value.isEmpty()) { - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - } else { - binding.tvErrorToLoadPlaceInfo.visibility = View.GONE - } - binding.rvPlaces.scrollToPosition(0) - } - } - - is PlaceListUiState.PlaceLoaded -> { - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { timeTag -> - childViewModel.updatePlacesByTimeTag(timeTag.timeTagId) - } - } - - is PlaceListUiState.Complete -> { - hideSkeleton() - } - - is PlaceListUiState.Error -> { - hideSkeleton() - binding.tvErrorToLoadPlaceInfo.visibility = View.VISIBLE - Timber.w(places.throwable, "PlaceListFragment: ${places.throwable.message}") - showErrorSnackBar(places.throwable) - } - } - } - viewModel.navigateToDetail.observe(viewLifecycleOwner) { selectedPlace -> startPlaceDetailActivity(selectedPlace) } @@ -171,62 +176,12 @@ class PlaceListFragment( childViewModel.updatePlacesByCategories(selectedCategories) } } - - viewModel.isExceededMaxLength.observe(viewLifecycleOwner) { isExceededMaxLength -> - moveToInitialPositionCallback.setIsExceededMaxLength(isExceededMaxLength) - binding.chipBackToInitialPosition.visibility = - if (isExceededMaxLength) View.VISIBLE else View.GONE - } - - viewModel.onMapViewClick.observe(viewLifecycleOwner) { - placeListBottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED - } - } - - private fun setUpBinding() { - binding.chipBackToInitialPosition.setOnClickListener { - viewModel.onBackToInitialPositionClicked() - binding.logger.log( - PlaceBackToSchoolClick( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - binding.rvPlaces.itemAnimator = null - } - - private fun setBehaviorCallback() { - moveToInitialPositionCallback = - MoveToInitialPositionCallback(binding.chipBackToInitialPosition.id) - - binding.lbvCurrentLocation - .placeListBottomSheetFollowBehavior() - ?.setCallback( - BottomSheetFollowCallback(binding.lbvCurrentLocation.id), - ) - - binding.chipBackToInitialPosition - .placeListBottomSheetFollowBehavior() - ?.setCallback(moveToInitialPositionCallback) } private fun startPlaceDetailActivity(place: PlaceUiModel) { viewModel.selectPlace(place.id) } - private fun showSkeleton() { - binding.tvErrorToLoadPlaceInfo.visibility = View.GONE - binding.rvPlaces.visibility = View.GONE - binding.sflScheduleSkeleton.visibility = View.VISIBLE - binding.sflScheduleSkeleton.startShimmer() - } - - private fun hideSkeleton() { - binding.rvPlaces.visibility = View.VISIBLE - binding.sflScheduleSkeleton.visibility = View.GONE - binding.sflScheduleSkeleton.stopShimmer() - } - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { Timber.d("start detail activity") val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) @@ -276,9 +231,6 @@ class PlaceListFragment( deferredList.add(deferred) } deferredList.awaitAll() - withContext(Dispatchers.Main) { - childViewModel.setPlacesStateComplete() - } } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt index 95fd917..184101a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.placeMap.placeList import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.model.PlaceCategory @@ -15,6 +16,9 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -30,6 +34,13 @@ class PlaceListViewModel( MutableLiveData(PlaceListUiState.Loading()) val places: LiveData>> = _places + val placesFlow: StateFlow>> = + _places.asFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = PlaceListUiState.Loading(), + ) + init { loadAllPlaces() } @@ -77,10 +88,6 @@ class PlaceListViewModel( _places.value = PlaceListUiState.Success(cachedPlaceByTimeTag) } - fun setPlacesStateComplete() { - _places.value = PlaceListUiState.Complete() - } - private fun loadAllPlaces() { viewModelScope.launch { val result = placeListRepository.getPlaces() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt deleted file mode 100644 index d7f3677..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceListAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceListAdapter( - private val handler: OnPlaceClickListener, -) : ListAdapter(DIFF_UTIL) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): PlaceViewHolder = PlaceViewHolder.from(parent, handler) - - override fun onBindViewHolder( - holder: PlaceViewHolder, - position: Int, - ) { - holder.bind(getItem(position)) - } - - companion object { - private val DIFF_UTIL = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: PlaceUiModel, - newItem: PlaceUiModel, - ): Boolean = oldItem == newItem - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt deleted file mode 100644 index 9c3b096..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/adapter/PlaceViewHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.databinding.ItemPlaceListBinding -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.placeList.OnPlaceClickListener - -class PlaceViewHolder private constructor( - private val binding: ItemPlaceListBinding, - private val listener: OnPlaceClickListener, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(placeUiModel: PlaceUiModel) { - binding.place = placeUiModel - binding.listener = listener - } - - companion object { - fun from( - parent: ViewGroup, - listener: OnPlaceClickListener, - ): PlaceViewHolder = - PlaceViewHolder( - ItemPlaceListBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - listener, - ) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt deleted file mode 100644 index b39c491..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/BottomSheetFollowCallback.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -open class BottomSheetFollowCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt deleted file mode 100644 index 5bfbcaa..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/MoveToInitialPositionCallback.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.view.View -import androidx.annotation.IdRes -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class MoveToInitialPositionCallback( - @IdRes private val viewId: Int, -) : BottomSheetBehavior.BottomSheetCallback() { - private lateinit var child: View - private var isExceededMaxLength = true - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (!::child.isInitialized) { - child = bottomSheet.rootView.findViewById(viewId) ?: return - } - if (newState == BottomSheetBehavior.STATE_EXPANDED || !isExceededMaxLength) { - child.visibility = View.GONE - } else { - child.visibility = View.VISIBLE - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - this.isExceededMaxLength = isExceededMaxLength - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt deleted file mode 100644 index c1f68eb..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetBehavior.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.FestaBookApp -import com.daedan.festabook.R -import com.daedan.festabook.logging.DefaultFirebaseLogger -import com.daedan.festabook.presentation.placeMap.logging.PlaceListSwipeUp -import com.google.android.material.bottomsheet.BottomSheetBehavior -import dev.zacsweers.metro.Inject - -class PlaceListBottomSheetBehavior( - context: Context, - attrs: AttributeSet, -) : BottomSheetBehavior( - context, - attrs, - ) { - private lateinit var recyclerView: RecyclerView - private var headerRange: IntRange = 0..0 - - @Inject - private lateinit var logger: DefaultFirebaseLogger - - init { - (context.applicationContext as FestaBookApp).festaBookGraph.inject(this) - state = STATE_HALF_EXPANDED - isGestureInsetBottomIgnored = true - addBottomSheetCallback( - object : BottomSheetCallback() { - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (newState == STATE_HALF_EXPANDED && ::recyclerView.isInitialized) { - recyclerView.scrollToPosition(HEADER_POSITION) - } - if (newState == STATE_EXPANDED) { - logger.log( - PlaceListSwipeUp( - baseLogData = logger.getBaseLogData(), - ), - ) - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) = Unit - }, - ) - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: V, - layoutDirection: Int, - ): Boolean { - recyclerView = child.findViewById(R.id.rv_places) - recyclerView.getChildAt(HEADER_POSITION)?.let { - headerRange = expandedOffset..(expandedOffset + it.height) - } - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onInterceptTouchEvent( - parent: CoordinatorLayout, - child: V, - event: MotionEvent, - ): Boolean { - if (event.action == MotionEvent.ACTION_DOWN && - state == STATE_EXPANDED && - event.y.toInt() in headerRange - ) { - state = STATE_COLLAPSED - } - return super.onInterceptTouchEvent(parent, child, event) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - if (!recyclerView.canScrollVertically(-1)) { - state = STATE_HALF_EXPANDED - } - } - - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - type: Int, - ) { - super.onStopNestedScroll(coordinatorLayout, child, target, type) - - if (!recyclerView.canScrollVertically(-1) && state == STATE_EXPANDED) { - state = STATE_HALF_EXPANDED - } - } - - fun setOffset(height: Int) { - expandedOffset = height - } - - companion object { - private const val HEADER_POSITION = 0 - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt deleted file mode 100644 index d0f07b3..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListBottomSheetFollowBehavior.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior - -class PlaceListBottomSheetFollowBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior( - context, - attrs, - ) { - private var currentBehavior: BottomSheetBehavior<*>? = null - private var callback: BottomSheetBehavior.BottomSheetCallback? = null - - override fun layoutDependsOn( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val behavior = (dependency.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior - if (behavior is BottomSheetBehavior<*>) { - currentBehavior = behavior - } - return behavior is BottomSheetBehavior<*> - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val bottomSheetTopY = dependency.y - dependency.height - child.translationY = bottomSheetTopY - return true - } - - override fun onDetachedFromLayoutParams() { - super.onDetachedFromLayoutParams() - callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - currentBehavior = null - } - - fun setCallback(callback: BottomSheetBehavior.BottomSheetCallback) { - this.callback?.let { - currentBehavior?.removeBottomSheetCallback(it) - } - this.callback = callback - currentBehavior?.addBottomSheetCallback(callback) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt deleted file mode 100644 index e827b1f..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/behavior/PlaceListScrollBehavior.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList.behavior - -import android.content.Context -import android.content.res.TypedArray -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.withStyledAttributes -import androidx.core.view.ViewCompat -import androidx.recyclerview.widget.RecyclerView -import com.daedan.festabook.R -import com.daedan.festabook.presentation.common.canScrollUp -import com.daedan.festabook.presentation.common.getSystemBarHeightCompat -import com.daedan.festabook.presentation.common.scrollAnimation -import com.google.android.material.chip.ChipGroup - -/** - * @deprecated - * @see - * 이 클래스는 더 이상 사용되지 않으며, 향후 버전에서 제거될 예정입니다. - * 대안으로 현재 PlaceListBottomSheetBehavior이 사용되고 있습니다 - * 네이버 지도 검색 UI를 본딴 동작을 수행합니다 - * 자세한 내용은 해당 링크를 참조해주세요 - * "https://github.com/woowacourse-teams/2025-festabook/pull/174" - */ -class PlaceListScrollBehavior( - context: Context, - attrs: AttributeSet, -) : CoordinatorLayout.Behavior() { - private lateinit var attribute: Attribute - private lateinit var state: BehaviorState - private var isInitialized: Boolean = false - private lateinit var minimumHeightView: View - - init { - context.withStyledAttributes(attrs, R.styleable.PlaceListScrollBehavior) { - setAttribute() - } - } - - override fun onLayoutChild( - parent: CoordinatorLayout, - child: ConstraintLayout, - layoutDirection: Int, - ): Boolean { - minimumHeightView = parent.findViewById(R.id.cg_categories) - if (!isInitialized) { - val recyclerView: RecyclerView? = parent.findViewById(attribute.recyclerViewId) - val companionView: View? = parent.findViewById(attribute.companionViewId) - isInitialized = true - - // 기기 높이 - 시스템 바 높이 - val rootViewHeight = child.rootView.height - child.getSystemBarHeightCompat() - child.translationY = rootViewHeight - attribute.initialY - state = BehaviorState(recyclerView, companionView, rootViewHeight) - } - state.companionView.setCompanionHeight(child) - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean = axes == ViewCompat.SCROLL_AXIS_VERTICAL - - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int, - ) { - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - state.companionView.setCompanionHeight(child) - - val isAlreadyConsumed = child.consumeIfRecyclerViewCanScrollUp(dy, consumed) - if (isAlreadyConsumed) return - child.consumeBackgroundLayoutScroll(dy, consumed) - } - - override fun onNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: ConstraintLayout, - target: View, - dxConsumed: Int, - dyConsumed: Int, - dxUnconsumed: Int, - dyUnconsumed: Int, - type: Int, - consumed: IntArray, - ) { - if (dyUnconsumed == 0) { - state.companionView?.visibility = View.GONE - } - super.onNestedScroll( - coordinatorLayout, - child, - target, - dxConsumed, - dyConsumed, - dxUnconsumed, - dyUnconsumed, - type, - consumed, - ) - } - - fun setOnScrollListener(listener: (dy: Float) -> Unit) { - state = state.copy(onScrollListener = listener) - } - - private fun TypedArray.setAttribute() { - val initialY = - getDimension(R.styleable.PlaceListScrollBehavior_initialY, UNINITIALIZED_VALUE) - val minimumY = - getDimension(R.styleable.PlaceListScrollBehavior_minimumY, UNINITIALIZED_VALUE) - val recyclerViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_recyclerView, - UNINITIALIZED_VALUE.toInt(), - ) - val companionViewId = - getResourceId( - R.styleable.PlaceListScrollBehavior_companionView, - UNINITIALIZED_VALUE.toInt(), - ) - attribute = - Attribute( - initialY, - minimumY, - recyclerViewId, - companionViewId, - ) - } - - private fun View?.setCompanionHeight(child: ConstraintLayout) { - this?.apply { - y = child.translationY - height - } - } - - private fun ViewGroup.consumeBackgroundLayoutScroll( - dy: Int, - consumed: IntArray, - ) { - apply { - // 최대 높이 (0일수록 천장에 가깝고, contentAreaHeight일수록 바닥에 가까움), 즉 maxHeight 까지만 스크롤을 내릴 수 있습니다 - val maxHeight = state.rootViewHeight - attribute.minimumY - val requestedTranslationY = translationY - dy - val newTranslationY = getNewTranslationY(requestedTranslationY, maxHeight) - - // 외부 레이아웃이 스크롤이 되었을 때만 스크롤 리스너 적용 - if (requestedTranslationY in minimumHeightView.height.toFloat()..maxHeight) { - state.onScrollListener?.invoke(dy.toFloat()) - } - translationY = newTranslationY - scrollAnimation(newTranslationY) - if (newTranslationY.toInt() == minimumHeightView.height) { - consumed[1] = 0 - } else { - consumed[1] = newTranslationY.toInt() - } - } - } - - private fun ViewGroup.getNewTranslationY( - requestedTranslationY: Float, - maxHeight: Float, - ): Float = requestedTranslationY.coerceIn(minimumHeightView.height.toFloat(), maxHeight) - - private fun ViewGroup.consumeIfRecyclerViewCanScrollUp( - dy: Int, - consumed: IntArray, - ): Boolean { - state.recyclerView?.let { - // 리사이클러 뷰가 위로 스크롤 될 수 있을 때 - if (dy < 0 && it.canScrollUp()) { - state.companionView?.visibility = View.VISIBLE - consumed[1] = 0 - return true - } - } - - return false - } - - private data class Attribute( - val initialY: Float, - val minimumY: Float, - val recyclerViewId: Int, - val companionViewId: Int, - ) - - private data class BehaviorState( - val recyclerView: RecyclerView?, - val companionView: View?, - val rootViewHeight: Int, - val onScrollListener: ((dy: Float) -> Unit)? = null, - ) - - companion object { - private const val UNINITIALIZED_VALUE = 0f - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt new file mode 100644 index 0000000..f85864b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt @@ -0,0 +1,52 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes + +@Composable +fun BackToPositionButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + AssistChip( + modifier = modifier, + onClick = onClick, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + }, + border = + AssistChipDefaults.assistChipBorder( + enabled = true, + borderColor = FestabookColor.black, + borderWidth = 1.dp, + ), + colors = + AssistChipDefaults.assistChipColors( + containerColor = FestabookColor.white, + ), + shape = festabookShapes.radiusFull, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BackToPositionButtonPreview() { + FestabookTheme { + BackToPositionButton( + text = "학교로 돌아가기", + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt new file mode 100644 index 0000000..dd4c4c1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.naver.maps.map.NaverMap +import com.naver.maps.map.widget.LocationButtonView + +@Composable +fun CurrentLocationButton( + modifier: Modifier = Modifier, + map: NaverMap? = null, +) { + AndroidView( + modifier = modifier, + factory = { context -> LocationButtonView(context) }, + update = { view -> view.map = map }, + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt new file mode 100644 index 0000000..28aa3ba --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt @@ -0,0 +1,32 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import kotlin.math.roundToInt + +@Composable +fun OffsetDependentLayout( + offset: Float, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Layout( + content = content, + modifier = modifier, + ) { measurables, constraints -> + val placeable = + measurables.firstOrNull()?.measure(constraints) ?: return@Layout layout( + width = constraints.minWidth, + height = constraints.minHeight, + placementBlock = { }, + ) + + // 부모의 크기를 결정 + layout(placeable.width, placeable.height + offset.roundToInt()) { + // offset만큼 배치 + val finalYPosition = offset.roundToInt() - placeable.height + placeable.placeRelative(x = 0, y = finalYPosition) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt new file mode 100644 index 0000000..44290d6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt @@ -0,0 +1,223 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookColor +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun PlaceListBottomSheet( + peekHeight: Dp, + halfExpandedRatio: Float, + modifier: Modifier = Modifier, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + shape: Shape = PlaceListBottomSheetDefault.bottomSheetBackgroundShape, + color: Color = PlaceListBottomSheetDefault.bottomSheetBackgroundColor, + onStateUpdate: (PlaceListBottomSheetValue) -> Unit = {}, + onScroll: (Float) -> Unit = {}, + dragHandle: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + require(halfExpandedRatio in 0.0..1.0) { "halfExpandedRatio는 0과 1 사이여야 합니다." } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val currentOnStateUpdate by rememberUpdatedState(onStateUpdate) + + LaunchedEffect(bottomSheetState.settledValue) { + currentOnStateUpdate(bottomSheetState.settledValue) + } + + val nestedScrollConnection = placeListBottomSheetNestedScrollConnection(bottomSheetState) + + Column( + modifier = + modifier + .fillMaxSize() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + // 실제 레이아웃 측정 시에만 앵커 설정 + if (!isLookingAhead) { + val screenHeightPx = constraints.maxHeight.toFloat() + // 3가지 앵커 높이 정의 (DP) + val halfExpandedOffsetPx = + screenHeightPx - screenHeightPx * halfExpandedRatio + val collapsedOffsetPx = with(density) { screenHeightPx - peekHeight.toPx() } + val expandedOffsetPx = 0f // 화면 최상단 + + bottomSheetState.state.updateAnchors( + newAnchors = + DraggableAnchors { + PlaceListBottomSheetValue.EXPANDED at expandedOffsetPx + PlaceListBottomSheetValue.HALF_EXPANDED at halfExpandedOffsetPx + PlaceListBottomSheetValue.COLLAPSED at collapsedOffsetPx + }, + newTarget = bottomSheetState.currentValue, + ) + // 스크롤 되었을 때 호출하는 콜백 + scope.launch { + snapshotFlow { bottomSheetState.state.requireOffset() } + .collect { currentOffset -> + onScroll(currentOffset) + } + } + } + + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + }.nestedScroll(nestedScrollConnection) + .offset { + IntOffset( + 0, + if (bottomSheetState.offset.isNaN()) 0 else bottomSheetState.offset.roundToInt(), + ) + }.background( + color = color, + shape = shape, + ).anchoredDraggable( + state = bottomSheetState.state, + orientation = Orientation.Vertical, + ), + ) { + PlaceListBottomSheetDefault.DefaultDragHandle() + dragHandle() + content() + } +} + +/** + * PlaceListBottomSheet의 기본 스타일을 정의합니다. + * 기본적인 DragHandle 컴포저블을 정의합니다. + */ +object PlaceListBottomSheetDefault { + val bottomSheetBackgroundShape: Shape = + RoundedCornerShape( + topStart = 30.dp, + topEnd = 30.dp, + ) + + val bottomSheetBackgroundColor: Color + @Composable + get() = FestabookColor.white + + private val dragHandleVerticalPadding = 12.dp + private val dragHandleWidth = 32.dp + private val dragHandleHeight = 4.dp + + private val dragHandleCorner = + RoundedCornerShape( + percent = 50, + ) + + private val dragHandleColor + @Composable + get() = FestabookColor.gray400 + + @Composable + fun DefaultDragHandle(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(vertical = dragHandleVerticalPadding) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = dragHandleWidth, height = dragHandleHeight) + .background( + color = dragHandleColor, + shape = dragHandleCorner, + ), + ) + } + } +} + +/** NestedScroll을 위한 Connection 객체를 반환합니다. + */ +private fun placeListBottomSheetNestedScrollConnection(placeListBottomSheetState: PlaceListBottomSheetState): NestedScrollConnection { + return object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + if (available.y < 0 && source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + if (source == NestedScrollSource.UserInput) { + placeListBottomSheetState.state.dispatchRawDelta(available.y).toOffset() + } else { + Offset.Zero + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { + placeListBottomSheetState.settleImmediately(available) + return available + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.y + val currentOffset = placeListBottomSheetState.state.requireOffset() + val minAnchor = placeListBottomSheetState.anchors.minPosition() + return if (toFling < 0 && currentOffset > minAnchor) { + placeListBottomSheetState.settleImmediately(available) + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset() = + Offset( + x = 0f, + y = this, + ) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt new file mode 100644 index 0000000..b16cb7f --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt @@ -0,0 +1,75 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Velocity + +class PlaceListBottomSheetState( + val state: AnchoredDraggableState, +) { + val anchors get() = state.anchors + val settledValue get() = state.settledValue + + val currentValue get() = state.currentValue + val offset get() = state.offset + + suspend fun update(newState: PlaceListBottomSheetValue) { + state.animateTo(newState) + } + + /** + anchoredState의 기본 settle() 동작은 거리 기반으로 동작합니다. + 거리 기반 동작을, 상태 기반으로 동작하도록 변경하여, 미세한 드래그에도 바텀시트가 펼쳐지도록 합니다. + */ + suspend fun settleImmediately( + available: Velocity, + animationSpec: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { + val targetState = + if (available.y < 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> state.currentValue + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.EXPANDED + PlaceListBottomSheetValue.COLLAPSED -> PlaceListBottomSheetValue.HALF_EXPANDED + } + } else if (available.y > 0) { + when (state.currentValue) { + PlaceListBottomSheetValue.EXPANDED -> PlaceListBottomSheetValue.HALF_EXPANDED + PlaceListBottomSheetValue.HALF_EXPANDED -> PlaceListBottomSheetValue.COLLAPSED + PlaceListBottomSheetValue.COLLAPSED -> state.currentValue + } + } else { + state.currentValue + } + + state.animateTo( + targetValue = targetState, + animationSpec = animationSpec, + ) + } +} + +enum class PlaceListBottomSheetValue { + EXPANDED, + HALF_EXPANDED, + COLLAPSED, +} + +@Composable +fun rememberPlaceListBottomSheetState( + initialState: PlaceListBottomSheetValue = PlaceListBottomSheetValue.HALF_EXPANDED, +): PlaceListBottomSheetState { + val anchoredState = + remember { + AnchoredDraggableState(initialValue = initialState) + } + + return remember(anchoredState) { + PlaceListBottomSheetState(anchoredState) + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt new file mode 100644 index 0000000..6b73ad6 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt @@ -0,0 +1,304 @@ +package com.daedan.festabook.presentation.placeMap.placeList.component + +import androidx.compose.foundation.clickable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.presentation.common.component.CoilImage +import com.daedan.festabook.presentation.common.component.EmptyStateScreen +import com.daedan.festabook.presentation.common.component.LoadingStateScreen +import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.launch + +@Composable +fun PlaceListScreen( + placesUiState: PlaceListUiState>, + modifier: Modifier = Modifier, + map: NaverMap? = null, + isExceedMaxLength: Boolean = false, + bottomSheetState: PlaceListBottomSheetState = + rememberPlaceListBottomSheetState( + PlaceListBottomSheetValue.HALF_EXPANDED, + ), + onPlaceClick: (place: PlaceUiModel) -> Unit = {}, + onPlaceLoadFinish: (places: List) -> Unit = {}, + onPlaceLoad: suspend () -> Unit = {}, + onError: (PlaceListUiState.Error>) -> Unit = {}, + onBackToInitialPositionClick: () -> Unit = {}, +) { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + var offset by remember { mutableFloatStateOf(0f) } + val currentOnPlaceLoad by rememberUpdatedState(onPlaceLoad) + + Box(modifier = modifier.fillMaxSize()) { + OffsetDependentLayout( + modifier = Modifier.padding(horizontal = festabookSpacing.paddingBody1), + offset = offset, + ) { + Box { + CurrentLocationButton( + map = map, + ) + if (isExceedMaxLength) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + BackToPositionButton( + text = stringResource(R.string.map_back_to_initial_position), + onClick = onBackToInitialPositionClick, + ) + } + } + } + } + + PlaceListBottomSheet( + peekHeight = 70.dp, + halfExpandedRatio = 0.4f, + onStateUpdate = { + if (listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + }, + onScroll = { offset = it }, + bottomSheetState = bottomSheetState, + dragHandle = { + Text( + text = stringResource(R.string.place_list_title), + style = MaterialTheme.typography.displayLarge, + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + bottom = festabookSpacing.paddingBody1, + ).padding(horizontal = festabookSpacing.paddingScreenGutter), + ) + }, + ) { + when (placesUiState) { + is PlaceListUiState.Loading -> + LoadingStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + + is PlaceListUiState.Error -> { + onError(placesUiState) + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } + + is PlaceListUiState.Success -> { + onPlaceLoadFinish(placesUiState.value) + if (placesUiState.value.isEmpty()) { + EmptyStateScreen( + modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), + ) + } else { + PlaceListContent( + places = placesUiState.value, + modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), + listState = listState, + onPlaceClick = onPlaceClick, + ) + } + } + + is PlaceListUiState.PlaceLoaded -> { + LaunchedEffect(Unit) { + scope.launch { + currentOnPlaceLoad() + } + } + } + } + } + } +} + +@Composable +private fun PlaceListContent( + places: List, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + LazyColumn( + state = listState, + modifier = modifier.fillMaxHeight(), + ) { + items( + items = places, + key = { place -> place.id }, + ) { place -> + PlaceListItem( + place = place, + onPlaceClick = onPlaceClick, + ) + } + } +} + +@Composable +private fun PlaceListItem( + place: PlaceUiModel, + modifier: Modifier = Modifier, + onPlaceClick: (PlaceUiModel) -> Unit = {}, +) { + Column( + modifier = + modifier + .padding(bottom = festabookSpacing.paddingBody3) + .clickable( + onClick = { onPlaceClick(place) }, + interactionSource = null, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CoilImage( + url = place.imageUrl ?: "", + contentDescription = stringResource(R.string.content_description_booth_image), + modifier = + Modifier + .size(80.dp) + .clip(festabookShapes.radius2), + ) + PlaceListItemContent( + modifier = + Modifier + .padding(start = festabookSpacing.paddingBody3) + .weight(1f), + place = place, + ) + } + HorizontalDivider( + modifier = + Modifier + .padding( + top = festabookSpacing.paddingBody4, + ), + ) + } +} + +private val HALF_EXPANDED_OFFSET = (-200).dp + +@Composable +private fun PlaceListItemContent( + place: PlaceUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + PlaceCategoryLabel( + category = place.category, + ) + Text( + modifier = Modifier.padding(top = festabookSpacing.paddingBody1), + text = place.title ?: stringResource(R.string.place_list_default_title), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(top = 2.dp), + text = + place.description + ?: stringResource(R.string.place_list_default_description), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource(R.drawable.ic_location), + contentDescription = stringResource(R.string.content_description_iv_location), + ) + Text( + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), + text = + place.location + ?: stringResource(R.string.place_list_default_location), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +private fun PlaceListScreenPreview() { + FestabookTheme { + PlaceListScreen( + placesUiState = + PlaceListUiState.Success( + (0..100).map { + PlaceUiModel( + id = it.toLong(), + imageUrl = null, + title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", + location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", + category = PlaceCategoryUiModel.FOOD_TRUCK, + isBookmarked = true, + timeTagId = listOf(1), + ) + }, + ), + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), + ) + } +} diff --git a/app/src/main/res/layout/fragment_place_list.xml b/app/src/main/res/layout/fragment_place_list.xml index 812dedb..f79db6c 100644 --- a/app/src/main/res/layout/fragment_place_list.xml +++ b/app/src/main/res/layout/fragment_place_list.xml @@ -1,5 +1,6 @@ + + android:layout_marginStart="8dp" /> - + app:chipStrokeWidth="1dp" /> \ No newline at end of file diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index a2b098a..d605eb1 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -6,7 +6,6 @@ import com.daedan.festabook.domain.repository.PlaceListRepository import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel import io.mockk.coEvery @@ -160,18 +159,4 @@ class PlaceListViewModelTest { val actual = placeListViewModel.places.getOrAwaitValue() assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) } - - @Test - fun `플레이스의 모든 정보가 로드가 완료되었을 때 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val expected = PlaceListUiState.Complete>() - - // when - placeListViewModel.setPlacesStateComplete() - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isInstanceOf(expected::class.java) - } }