diff --git a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt index 4687ae9..8df85db 100644 --- a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt +++ b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerBindings.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.di.mapManager -import com.daedan.festabook.presentation.placeMap.MapClickListener -import com.daedan.festabook.presentation.placeMap.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListenerImpl import com.daedan.festabook.presentation.placeMap.mapManager.internal.OverlayImageManager import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.iconResources diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt new file mode 100644 index 0000000..a951751 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaceByTimeTag diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt new file mode 100644 index 0000000..26f6491 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt @@ -0,0 +1,6 @@ +package com.daedan.festabook.di.placeMapHandler + +import dev.zacsweers.metro.Qualifier + +@Qualifier +annotation class CachedPlaces diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt new file mode 100644 index 0000000..caf5ba4 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt @@ -0,0 +1,38 @@ +package com.daedan.festabook.di.placeMapHandler + +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterActionHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.MapEventActionHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectActionHandler +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow + +@GraphExtension(PlaceMapViewModelScope::class) +interface PlaceMapHandlerGraph { + val filterActionHandler: FilterActionHandler + val selectActionHandler: SelectActionHandler + val mapEventActionHandler: MapEventActionHandler + + @ContributesTo(AppScope::class) + @GraphExtension.Factory + interface Factory { + fun create( + @Provides mapControlUiEvent: Channel, + @Provides placeMapUiEvent: Channel, + @Provides uiState: StateFlow, + @Provides @CachedPlaces cachedPlaces: StateFlow>, + @Provides @CachedPlaceByTimeTag cachedPlaceByTimeTag: StateFlow>, + @Provides onUpdateCachedPlace: (List) -> Unit, + @Provides onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, + @Provides scope: CoroutineScope, + ): PlaceMapHandlerGraph + } +} diff --git a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt new file mode 100644 index 0000000..cbf9f4b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.di.placeMapHandler + +abstract class PlaceMapViewModelScope private constructor() diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt new file mode 100644 index 0000000..f5e67fb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt similarity index 97% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt index 2b1e0ad..8546f3f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/CategoryView.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeDetail/CategoryView.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList +package com.daedan.festabook.presentation.placeDetail import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index 8c7a65f..058e94b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -2,52 +2,56 @@ package com.daedan.festabook.presentation.placeMap import android.content.Context import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import android.view.ViewGroup import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import coil3.ImageLoader +import coil3.asImage +import coil3.request.ImageRequest +import coil3.request.ImageResult import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding +import com.daedan.festabook.di.appGraph import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.di.mapManager.MapManagerGraph import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment +import com.daedan.festabook.presentation.common.ObserveAsEvents import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.component.NaverMapContent -import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen +import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.handler.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.intent.handler.PlaceMapEventHandler +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter -import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected -import com.daedan.festabook.presentation.placeMap.mapManager.MapManager -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeCategory.PlaceCategoryFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewFragment -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewSecondaryFragment -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu -import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.NaverMap -import com.naver.maps.map.OnMapReadyCallback import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding -import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber @ContributesIntoMap( @@ -57,235 +61,137 @@ import timber.log.Timber @FragmentKey(PlaceMapFragment::class) @Inject class PlaceMapFragment( - placeListFragment: PlaceListFragment, - placeDetailPreviewFragment: PlaceDetailPreviewFragment, - placeCategoryFragment: PlaceCategoryFragment, - placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, override val defaultViewModelProviderFactory: ViewModelProvider.Factory, ) : BaseFragment(), OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map - private lateinit var naverMap: NaverMap - - private val placeListFragment by lazy { getIfExists(placeListFragment) } - private val placeDetailPreviewFragment by lazy { getIfExists(placeDetailPreviewFragment) } - private val placeCategoryFragment by lazy { getIfExists(placeCategoryFragment) } - private val placeDetailPreviewSecondaryFragment by lazy { - getIfExists( - placeDetailPreviewSecondaryFragment, - ) - } private val locationSource by lazy { FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) } - private var mapManager: MapManager? = null - - private val viewModel: PlaceMapViewModel by viewModels() + private val placeMapViewModel: PlaceMapViewModel by viewModels() - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - if (savedInstanceState == null) { - childFragmentManager.commit { - addWithSimpleTag(R.id.fcv_place_list_container, placeListFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewFragment) - addWithSimpleTag(R.id.fcv_place_category_container, placeCategoryFragment) - addWithSimpleTag(R.id.fcv_map_container, placeDetailPreviewSecondaryFragment) - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - } - } - - setupComposeView() - + ): View { + super.onCreateView(inflater, container, savedInstanceState) binding.logger.log( PlaceFragmentEnter( baseLogData = binding.logger.getBaseLogData(), ), ) - } - - override fun onMenuItemReClick() { - val childFragments = - listOf( - placeListFragment, - placeDetailPreviewFragment, - placeCategoryFragment, - ) - childFragments.forEach { fragment -> - (fragment as? OnMenuItemReClickListener)?.onMenuItemReClick() - } - mapManager?.moveToPosition() - } - - private fun setupComposeView() { - binding.cvPlaceMap.apply { + return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - FestabookTheme { - NaverMapContent( - modifier = Modifier.fillMaxSize(), - onMapDrag = { viewModel.onMapViewClick() }, - onMapReady = { setupMap(it) }, - ) { - // TODO 흩어져있는 ComposeView 통합, 추후 PlaceMapScreen 사용 + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() + val density = LocalDensity.current + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapDelegate = remember { MapDelegate() } + val mapManagerDelegate = remember { MapManagerDelegate() } + val mapControlEventHandler = + remember { + MapControlEventHandler( + initialPadding = with(density) { 254.dp.toPx() }.toInt(), + logger = appGraph.defaultFirebaseLogger, + locationSource = locationSource, + viewModel = placeMapViewModel, + mapDelegate = mapDelegate, + mapManagerDelegate = mapManagerDelegate, + ) } - } - } - } - binding.cvTimeTagSpinner.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() - val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() - FestabookTheme { - if (timeTags.isNotEmpty()) { - TimeTagMenu( - title = title.name, - timeTags = timeTags, - onTimeTagClick = { timeTag -> - viewModel.onDaySelected(timeTag) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = timeTag.name, - ), - ) - }, - modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), + val placeMapEventHandler = + remember { + PlaceMapEventHandler( + mapManagerDelegate = mapManagerDelegate, + bottomSheetState = bottomSheetState, + viewModel = placeMapViewModel, + logger = appGraph.defaultFirebaseLogger, + onStartPlaceDetail = { startPlaceDetailActivity(it.placeDetail.value) }, + onPreloadImages = { preloadImages(requireContext(), it.places) }, + onShowErrorSnackBar = { showErrorSnackBar(it.error.throwable) }, ) } - } - } - } - } - - private fun setupMap(map: NaverMap) { - naverMap = map - naverMap.addOnLocationChangeListener { - binding.logger.log( - CurrentLocationChecked( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - (placeListFragment as? OnMapReadyCallback)?.onMapReady(naverMap) - naverMap.locationSource = locationSource - setUpObserver() - } - private fun setUpObserver() { - viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> - when (placeGeographies) { - is PlaceListUiState.Loading -> Unit - is PlaceListUiState.Success -> { - mapManager?.setupMarker(placeGeographies.value) - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { selectedTimeTag -> - mapManager?.filterMarkersByTimeTag(selectedTimeTag.timeTagId) - } + ObserveAsEvents(flow = placeMapViewModel.mapControlUiEvent) { event -> + mapControlEventHandler(event) } - is PlaceListUiState.Error -> { - Timber.w( - placeGeographies.throwable, - "PlaceListFragment: ${placeGeographies.throwable.message}", - ) - showErrorSnackBar(placeGeographies.throwable) + ObserveAsEvents(flow = placeMapViewModel.placeMapUiEvent) { event -> + placeMapEventHandler(event) } - else -> Unit - } - } - - viewModel.initialMapSetting.observe(viewLifecycleOwner) { initialMapSetting -> - if (initialMapSetting !is PlaceListUiState.Success) return@observe - if (mapManager == null) { - val graph = - createGraphFactory().create( - naverMap, - initialMapSetting.value, - viewModel, - getInitialPadding(requireContext()), + FestabookTheme { + PlaceMapScreen( + uiState = uiState, + onAction = { placeMapViewModel.onPlaceMapAction(it) }, + bottomSheetState = bottomSheetState, + mapDelegate = mapDelegate, ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - viewModel.setIsExceededMaxLength(isExceededMaxLength) } } } + } - viewModel.backToInitialPositionClicked.observe(viewLifecycleOwner) { - mapManager?.moveToPosition() - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) - } - } + override fun onMenuItemReClick() { + placeMapViewModel.onPlaceMapAction(SelectAction.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() + } - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - childFragmentManager.commit { - setReorderingAllowed(true) + private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { + Timber.d("start detail activity") + val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) + startActivity(intent) + } - when (selectedPlace) { - is SelectedPlaceUiState.Success -> { - mapManager?.selectMarker(selectedPlace.value.place.id) - if (selectedPlace.isSecondary) { - hide(placeListFragment) - hide(placeDetailPreviewFragment) - show(placeDetailPreviewSecondaryFragment) - } else { - hide(placeListFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeDetailPreviewFragment) + // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 + private fun preloadImages( + context: Context, + places: List, + maxSize: Int = 20, + ) { + val imageLoader = ImageLoader(context) + val deferredList = mutableListOf>() + val defaultImage = + ContextCompat + .getDrawable( + context, + R.drawable.img_fallback, + )?.asImage() + + lifecycleScope.launch { + places + .take(maxSize) + .filterNotNull() + .forEach { place -> + val deferred = + async { + val request = + ImageRequest + .Builder(context) + .data(place.imageUrl) + .error { + defaultImage + }.fallback { + defaultImage + }.build() + + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + imageLoader.shutdown() + }.getOrNull() } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = selectedPlace.value.place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - } - - is SelectedPlaceUiState.Empty -> { - mapManager?.unselectMarker() - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeListFragment) - } - - else -> Unit + deferredList.add(deferred) } - } + deferredList.awaitAll() } } - @Suppress("UNCHECKED_CAST") - private fun getIfExists(fragment: T): T = - childFragmentManager.findFragmentByTag(fragment::class.simpleName) as? T ?: fragment - - private fun FragmentTransaction.addWithSimpleTag( - containerViewId: Int, - fragment: Fragment, - ) { - add(containerViewId, fragment, fragment::class.simpleName) - } - companion object { private const val LOCATION_PERMISSION_REQUEST_CODE = 1234 - - private fun getInitialPadding(context: Context): Int = 254.toPx(context) } } 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 5cefacf..f98fc51 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 @@ -1,33 +1,38 @@ package com.daedan.festabook.presentation.placeMap -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.placeMapHandler.PlaceMapHandlerGraph import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.common.SingleLiveData -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.action.PlaceMapAction +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel 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.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -35,154 +40,153 @@ import kotlinx.coroutines.launch @Inject class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, - private val placeDetailRepository: PlaceDetailRepository, + handlerGraphFactory: PlaceMapHandlerGraph.Factory, ) : ViewModel() { - private val _initialMapSetting: MutableLiveData> = - MutableLiveData() - val initialMapSetting: LiveData> = _initialMapSetting - - private val _placeGeographies: MutableLiveData>> = - MutableLiveData() - val placeGeographies: LiveData>> - get() = _placeGeographies - - private val _timeTags = MutableStateFlow>(emptyList()) - val timeTags: StateFlow> = _timeTags.asStateFlow() - - private val _selectedTimeTag = MutableLiveData() - val selectedTimeTag: LiveData = _selectedTimeTag - - // 임시 StateFlow - val selectedTimeTagFlow: StateFlow = - _selectedTimeTag.asFlow().stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = TimeTag.EMPTY, - ) - private val _selectedPlace: MutableLiveData = MutableLiveData() - val selectedPlace: LiveData = _selectedPlace - - val selectedPlaceFlow: StateFlow = - _selectedPlace - .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = SelectedPlaceUiState.Loading, - ) - - private val _navigateToDetail = SingleLiveData() - val navigateToDetail: LiveData = _navigateToDetail - - private val _isExceededMaxLength: MutableLiveData = MutableLiveData() - val isExceededMaxLength: LiveData = _isExceededMaxLength - - val isExceededMaxLengthFlow: StateFlow = - _isExceededMaxLength - .asFlow() - .stateIn( + private val cachedPlaces = MutableStateFlow(listOf()) + private val cachedPlaceByTimeTag = MutableStateFlow>(emptyList()) + + private val _uiState = MutableStateFlow(PlaceMapUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _placeMapUiEvent = Channel() + val placeMapUiEvent: Flow = _placeMapUiEvent.receiveAsFlow() + + private val _mapControlUiEvent = Channel() + val mapControlUiEvent: Flow = _mapControlUiEvent.receiveAsFlow() + + private val handlerGraph = + handlerGraphFactory + .create( + mapControlUiEvent = _mapControlUiEvent, + placeMapUiEvent = _placeMapUiEvent, + uiState = uiState, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + onUpdateState = { _uiState.update(it) }, scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = false, ) - private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() - val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked - - private val _selectedCategories: MutableLiveData> = MutableLiveData() - val selectedCategories: LiveData> = _selectedCategories - - private val _onMapViewClick: MutableLiveData> = MutableLiveData() - val onMapViewClick: LiveData> = _onMapViewClick - - val onMapViewClickFlow: Flow> = - _onMapViewClick - .asFlow() - init { loadOrganizationGeography() loadTimeTags() + loadAllPlaces() + observeErrorEvent() } - private fun loadTimeTags() { + fun onPlaceMapAction(action: PlaceMapAction) { viewModelScope.launch { - placeListRepository - .getTimeTags() - .onSuccess { timeTags -> - _timeTags.value = timeTags - }.onFailure { - _timeTags.value = emptyList() - } - - // 기본 선택값 - if (!timeTags.value.isEmpty()) { - _selectedTimeTag.value = _timeTags.value.first() - } else { - _selectedTimeTag.value = TimeTag.EMPTY + when (action) { + is FilterAction -> handlerGraph.filterActionHandler(action) + is MapEventAction -> handlerGraph.mapEventActionHandler(action) + is SelectAction -> handlerGraph.selectActionHandler(action) } } } - fun onDaySelected(item: TimeTag) { - unselectPlace() - _selectedTimeTag.value = item + fun onMenuItemReClicked() { + _placeMapUiEvent.trySend( + PlaceMapEvent.MenuItemReClicked( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) } - fun selectPlace(placeId: Long) { + private fun loadTimeTags() { viewModelScope.launch { - _selectedPlace.value = SelectedPlaceUiState.Loading - placeDetailRepository - .getPlaceDetail(placeId = placeId) - .onSuccess { - _selectedPlace.value = SelectedPlaceUiState.Success(it.toUiModel()) + placeListRepository + .getTimeTags() + .onSuccess { timeTags -> + _uiState.update { + it.copy( + timeTags = LoadState.Success(timeTags), + ) + } }.onFailure { - _selectedPlace.value = SelectedPlaceUiState.Error(it) + _uiState.update { + it.copy( + timeTags = LoadState.Empty, + ) + } } - } - } - fun unselectPlace() { - _selectedPlace.value = SelectedPlaceUiState.Empty - } + // 기본 선택값 + val timeTags = uiState.value.timeTags + val selectedTimeTag = + if (timeTags is LoadState.Success && timeTags.value.isNotEmpty()) { + LoadState.Success( + timeTags.value.first(), + ) + } else { + LoadState.Empty + } + _uiState.update { + it.copy(selectedTimeTag = selectedTimeTag) + } - fun onExpandedStateReached() { - val currentPlace = _selectedPlace.value.let { it as? SelectedPlaceUiState.Success }?.value - if (currentPlace != null) { - _navigateToDetail.setValue(currentPlace) + val placeGeographies = + uiState.await>> { it.placeGeographies } + _mapControlUiEvent.send( + MapControlEvent.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = selectedTimeTag, + isInitial = true, + ), + ) } } - fun onBackToInitialPositionClicked() { - _backToInitialPositionClicked.value = Event(Unit) - } - - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - _isExceededMaxLength.value = isExceededMaxLength - } - - fun setSelectedCategories(categories: List) { - _selectedCategories.value = categories - } - - fun onMapViewClick() { - _onMapViewClick.value = Event(Unit) - } - private fun loadOrganizationGeography() { viewModelScope.launch { placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _initialMapSetting.value = - PlaceListUiState.Success(organizationGeography.toUiModel()) + _uiState.update { + it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) + } } launch { placeListRepository .getPlaceGeographies() .onSuccess { placeGeographies -> - _placeGeographies.value = - PlaceListUiState.Success(placeGeographies.map { it.toUiModel() }) - }.onFailure { - _placeGeographies.value = PlaceListUiState.Error(it) + _uiState.update { + it.copy( + placeGeographies = LoadState.Success(placeGeographies.map { it.toUiModel() }), + ) + } + }.onFailure { item -> + _uiState.update { + it.copy(placeGeographies = LoadState.Error(item)) + } + } + } + } + } + + private fun loadAllPlaces() { + viewModelScope.launch { + val result = placeListRepository.getPlaces() + result + .onSuccess { places -> + val placeUiModels = places.map { it.toUiModel() } + cachedPlaces.tryEmit(placeUiModels) + _uiState.update { it.copy(places = ListLoadState.PlaceLoaded(placeUiModels)) } + }.onFailure { error -> + _uiState.update { it.copy(places = ListLoadState.Error(error)) } + } + } + } + + @OptIn(FlowPreview::class) + private fun observeErrorEvent() { + viewModelScope.launch { + launch { + uiState + .map { it.hasAnyError } + .distinctUntilChanged() + .filterIsInstance() + .debounce(1000) + .collect { + _placeMapUiEvent.send(PlaceMapEvent.ShowErrorSnackBar(it)) } } } 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/component/BackToPositionButton.kt similarity index 95% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt index f85864b..676e2dd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/BackToPositionButton.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/BackToPositionButton.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults 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/component/CurrentLocationButton.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt index dd4c4c1..e91d936 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/CurrentLocationButton.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/CurrentLocationButton.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index aa72f6e..ed43be5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt @@ -5,6 +5,7 @@ import android.content.res.Configuration import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,28 +18,31 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate import com.naver.maps.map.MapView import com.naver.maps.map.NaverMap +import kotlinx.coroutines.suspendCancellableCoroutine @Composable fun NaverMapContent( modifier: Modifier = Modifier, + mapDelegate: MapDelegate = MapDelegate(), onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, - content: @Composable () -> Unit, + content: @Composable (NaverMap?) -> Unit, ) { val context = LocalContext.current val mapView = remember { MapView(context) } + LaunchedEffect(mapView) { + val naverMap = mapView.getMapAndRunCallback(onMapReady) + mapDelegate.initMap(naverMap) + } AndroidView( - factory = { - mapView.apply { - getMapAsync(onMapReady) - } - }, + factory = { mapView }, modifier = modifier.dragInterceptor(onMapDrag), ) RegisterMapLifeCycle(mapView) - content() + content(mapDelegate.value) } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = @@ -152,3 +156,13 @@ private fun MapView.lifecycleObserver( } previousState.value = event } + +private suspend fun MapView.getMapAndRunCallback(onMapReady: (NaverMap) -> Unit = {}): NaverMap = + suspendCancellableCoroutine { continuation -> + getMapAsync { map -> + onMapReady(map) + continuation.resumeWith( + Result.success(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/component/OffsetDependentLayout.kt similarity index 93% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt index 28aa3ba..5202e4a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/OffsetDependentLayout.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/OffsetDependentLayout.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier 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 index e0315a7..cd52188 100644 --- 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 @@ -21,6 +21,7 @@ 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 com.daedan.festabook.presentation.theme.festabookSpacing import kotlin.math.roundToInt @Composable @@ -51,7 +52,7 @@ fun PlaceCategoryLabel( modifier = Modifier.size(12.dp), ) Text( - modifier = Modifier.padding(start = 4.dp), + modifier = Modifier.padding(start = festabookSpacing.paddingBody1), text = stringResource(category.getTextId()), style = MaterialTheme.typography.labelMedium, ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt similarity index 98% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt index 723992d..fc551b8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/component/PlaceCategoryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceCategoryScreen.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeCategory.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 0e4c101..caff647 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -1,5 +1,6 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,16 +16,14 @@ 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.URLText import com.daedan.festabook.presentation.common.convertImageUrl import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography @@ -33,28 +32,28 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewScreen( - placeUiState: SelectedPlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, visible: Boolean = false, - onClick: (SelectedPlaceUiState) -> Unit = {}, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = modifier .wrapContentSize() - .clickable { onClick(placeUiState) }, + .clickable { onClick(selectedPlace) }, ) { - when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Success -> { - PlaceDetailPreviewContent(placeDetail = placeUiState.value) + when (selectedPlace) { + is LoadState.Success -> { + PlaceDetailPreviewContent(placeDetail = selectedPlace.value) } - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() + else -> Unit } } } @@ -68,7 +67,7 @@ private fun PlaceDetailPreviewContent( modifier = modifier.padding( horizontal = festabookSpacing.paddingScreenGutter, - vertical = 20.dp, + vertical = festabookSpacing.previewVerticalPadding, ), ) { PlaceCategoryLabel( @@ -146,7 +145,7 @@ private fun PlaceDetailPreviewContent( CoilImage( modifier = Modifier - .size(88.dp) + .size(festabookSpacing.previewImageSize) .clip(festabookShapes.radius2), url = placeDetail.place.imageUrl.convertImageUrl() ?: "", contentDescription = stringResource(R.string.content_description_booth_image), @@ -184,8 +183,8 @@ private fun PlaceDetailPreviewScreenPreview() { modifier = Modifier .padding(festabookSpacing.paddingScreenGutter), - placeUiState = - SelectedPlaceUiState.Success( + selectedPlace = + LoadState.Success( value = FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt similarity index 80% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index f6a3ba2..2666046 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -1,5 +1,6 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -17,9 +18,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId import com.daedan.festabook.presentation.theme.FestabookTheme @@ -29,28 +30,27 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewSecondaryScreen( - placeUiState: SelectedPlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, - onClick: (SelectedPlaceUiState) -> Unit = {}, + onClick: (LoadState) -> Unit = {}, + onBackPress: () -> Unit = {}, visible: Boolean = false, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = modifier .fillMaxWidth() .clickable { - onClick(placeUiState) + onClick(selectedPlace) }, shape = festabookShapes.radius2, ) { - when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() - is SelectedPlaceUiState.Success -> { + when (selectedPlace) { + is LoadState.Success -> { Row( modifier = Modifier.padding( @@ -63,7 +63,7 @@ fun PlaceDetailPreviewSecondaryScreen( modifier = Modifier.size(24.dp), painter = painterResource( - placeUiState.value.place.category + selectedPlace.value.place.category .getIconId(), ), tint = Color.Unspecified, @@ -73,15 +73,17 @@ fun PlaceDetailPreviewSecondaryScreen( Text( modifier = Modifier.padding(start = festabookSpacing.paddingBody2), text = - placeUiState.value.place.title + selectedPlace.value.place.title ?: stringResource( - placeUiState.value.place.category + selectedPlace.value.place.category .getTextId(), ), style = FestabookTypography.displaySmall, ) } } + + else -> Unit } } } @@ -93,8 +95,8 @@ private fun PlaceDetailPreviewSecondaryScreenPreview() { PlaceDetailPreviewSecondaryScreen( visible = true, modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), - placeUiState = - SelectedPlaceUiState.Success( + selectedPlace = + LoadState.Success( FAKE_PLACE_DETAIL, ), ) 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/component/PlaceListBottomSheet.kt similarity index 99% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt index 44290d6..0089b87 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheet.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheet.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background import androidx.compose.foundation.gestures.DraggableAnchors 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/component/PlaceListBottomSheetState.kt similarity index 97% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt index b16cb7f..3cdf12f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListBottomSheetState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListBottomSheetState.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring 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/component/PlaceListScreen.kt similarity index 83% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 6b73ad6..6b32fb1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -39,9 +39,8 @@ 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.intent.state.ListLoadState 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 @@ -51,10 +50,10 @@ import kotlinx.coroutines.launch @Composable fun PlaceListScreen( - placesUiState: PlaceListUiState>, + placesUiState: ListLoadState>, modifier: Modifier = Modifier, map: NaverMap? = null, - isExceedMaxLength: Boolean = false, + isExceededMaxLength: Boolean = false, bottomSheetState: PlaceListBottomSheetState = rememberPlaceListBottomSheetState( PlaceListBottomSheetValue.HALF_EXPANDED, @@ -62,40 +61,52 @@ fun PlaceListScreen( 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) + val currentOnPlaceLoadFinish by rememberUpdatedState(onPlaceLoadFinish) + + LaunchedEffect(placesUiState) { + when (placesUiState) { + is ListLoadState.PlaceLoaded -> launch { currentOnPlaceLoad() } + is ListLoadState.Success -> currentOnPlaceLoadFinish(placesUiState.value) + else -> Unit + } + } 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, - ) + if (bottomSheetState.currentValue != PlaceListBottomSheetValue.EXPANDED) { + OffsetDependentLayout( + modifier = + Modifier + .padding(horizontal = festabookSpacing.paddingBody1), + offset = offset, + ) { + Box { + CurrentLocationButton( + map = map, + ) + if (isExceededMaxLength) { + 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, + peekHeight = festabookSpacing.placeListBottomSheetPeekHeight, + halfExpandedRatio = festabookSpacing.placeListBottomSheetHalfRatio, onStateUpdate = { if (listState.firstVisibleItemIndex != 0) { scope.launch { listState.scrollToItem(0) } @@ -117,20 +128,18 @@ fun PlaceListScreen( }, ) { when (placesUiState) { - is PlaceListUiState.Loading -> + is ListLoadState.Loading -> LoadingStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) - is PlaceListUiState.Error -> { - onError(placesUiState) + is ListLoadState.Error -> { EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) } - is PlaceListUiState.Success -> { - onPlaceLoadFinish(placesUiState.value) + is ListLoadState.Success -> { if (placesUiState.value.isEmpty()) { EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), @@ -145,13 +154,7 @@ fun PlaceListScreen( } } - is PlaceListUiState.PlaceLoaded -> { - LaunchedEffect(Unit) { - scope.launch { - currentOnPlaceLoad() - } - } - } + is ListLoadState.PlaceLoaded -> Unit } } } @@ -203,7 +206,7 @@ private fun PlaceListItem( contentDescription = stringResource(R.string.content_description_booth_image), modifier = Modifier - .size(80.dp) + .size(festabookSpacing.placeListImageSize) .clip(festabookShapes.radius2), ) PlaceListItemContent( @@ -281,7 +284,7 @@ private fun PlaceListScreenPreview() { FestabookTheme { PlaceListScreen( placesUiState = - PlaceListUiState.Success( + ListLoadState.Success( (0..100).map { PlaceUiModel( id = it.toLong(), 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 1d3d721..3684b08 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 @@ -1,101 +1,121 @@ package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box 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.Alignment 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 androidx.compose.ui.draw.alpha +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.action.PlaceMapAction +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.naver.maps.map.NaverMap +import com.daedan.festabook.presentation.theme.festabookSpacing -@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, - onTimeTagClick: (TimeTag) -> Unit, + uiState: PlaceMapUiState, + onAction: (PlaceMapAction) -> Unit, + bottomSheetState: PlaceListBottomSheetState, + mapDelegate: MapDelegate, modifier: Modifier = Modifier, ) { NaverMapContent( modifier = modifier.fillMaxSize(), - onMapReady = onMapReady, - ) { + mapDelegate = mapDelegate, + onMapReady = { onAction(MapEventAction.OnMapReady) }, + onMapDrag = { onAction(MapEventAction.OnMapDrag) }, + ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), ) { - if (timeTags.isNotEmpty()) { - TimeTagMenu( - title = title, - timeTags = timeTags, - onTimeTagClick = { timeTag -> - onTimeTagClick(timeTag) - }, + TimeTagMenu( + timeTagsState = uiState.timeTags, + selectedTimeTagState = uiState.selectedTimeTag, + onTimeTagClick = { timeTag -> + onAction(SelectAction.OnTimeTagClick(timeTag)) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = festabookSpacing.timeTagHorizontalPadding), + ) + PlaceCategoryScreen( + initialCategories = uiState.initialCategories, + selectedCategories = uiState.selectedCategories, + onCategoryClick = { onAction(FilterAction.OnCategoryClick(it)) }, + onDisplayAllClick = { onAction(FilterAction.OnCategoryClick(it)) }, + ) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + NaverMapLogo( modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), ) - } - 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), + PlaceListScreen( + modifier = + Modifier.alpha( + if (uiState.selectedPlace is LoadState.Empty) { + 1f + } else { + 0f + }, + ), + placesUiState = uiState.places, + map = naverMap, + onPlaceClick = { onAction(SelectAction.OnPlaceClick(it.id)) }, + bottomSheetState = bottomSheetState, + isExceededMaxLength = uiState.isExceededMaxLength, + onPlaceLoadFinish = { onAction(MapEventAction.OnPlaceLoadFinish(it)) }, + onPlaceLoad = { onAction(FilterAction.OnPlaceLoad) }, + onBackToInitialPositionClick = { onAction(MapEventAction.OnBackToInitialPositionClick) }, + ) + + if (uiState.isPlacePreviewVisible) { + PlaceDetailPreviewScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onClick = { onAction(SelectAction.OnPlacePreviewClick(it)) }, + onBackPress = { onAction(SelectAction.OnBackPress) }, ) - }, - ) + } + + if (uiState.isPlaceSecondaryPreviewVisible) { + PlaceDetailPreviewSecondaryScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onBackPress = { onAction(SelectAction.OnBackPress) }, + ) + } + } + } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt similarity index 96% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt index 0d478d9..495dbbe 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PreviewAnimatableBox.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PreviewAnimatableBox.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt new file mode 100644 index 0000000..c4aa967 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt @@ -0,0 +1,25 @@ +package com.daedan.festabook.presentation.placeMap.component + +import androidx.compose.ui.unit.dp +import com.daedan.festabook.presentation.theme.FestabookSpacing + +val FestabookSpacing.previewVerticalPadding + get() = 20.dp + +val FestabookSpacing.timeTagHorizontalPadding + get() = 24.dp + +val FestabookSpacing.previewImageSize + get() = 88.dp + +val FestabookSpacing.placeListImageSize + get() = 80.dp + +val FestabookSpacing.placeListBottomSheetPeekHeight + get() = 70.dp + +val FestabookSpacing.placeListBottomSheetHalfRatio + get() = 0.4f + +val FestabookSpacing.timeTagButtonWidth + get() = 140.dp diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt similarity index 88% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index 769f9a5..5b535f8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.timeTagSpinner.component +package com.daedan.festabook.presentation.placeMap.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import com.daedan.festabook.R import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookShapes @@ -51,6 +52,29 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimeTagMenu( + timeTagsState: LoadState>, + selectedTimeTagState: LoadState, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + when (timeTagsState) { + is LoadState.Success -> { + if (selectedTimeTagState !is LoadState.Success) return + TimeTagContent( + title = selectedTimeTagState.value.name, + timeTags = timeTagsState.value, + modifier = modifier, + onTimeTagClick = onTimeTagClick, + ) + } + + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimeTagContent( title: String, timeTags: List, modifier: Modifier = Modifier, @@ -124,7 +148,7 @@ private fun ExposedDropdownMenuBoxScope.TimeTagButton( Row( modifier = Modifier - .width(140.dp) + .width(festabookSpacing.timeTagButtonWidth) .onGloballyPositioned { coordinates -> onSizeDetermine(coordinates.size) }.menuAnchor( @@ -170,7 +194,7 @@ private fun TimeTagMenuPreview() { ) var title by remember { mutableStateOf("1일차 오전") } FestabookTheme { - TimeTagMenu( + TimeTagContent( title = title, timeTags = timeTags, modifier = diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterAction.kt new file mode 100644 index 0000000..66affb5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterAction.kt @@ -0,0 +1,11 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface FilterAction : PlaceMapAction { + data class OnCategoryClick( + val categories: Set, + ) : FilterAction + + data object OnPlaceLoad : FilterAction +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventAction.kt new file mode 100644 index 0000000..ed3835a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventAction.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface MapEventAction : PlaceMapAction { + data object OnMapReady : MapEventAction + + data object OnMapDrag : MapEventAction + + data class OnPlaceLoadFinish( + val places: List, + ) : MapEventAction + + data object OnBackToInitialPositionClick : MapEventAction +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/PlaceMapAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/PlaceMapAction.kt new file mode 100644 index 0000000..cce88b7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/PlaceMapAction.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +sealed interface PlaceMapAction diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectAction.kt new file mode 100644 index 0000000..6da088b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectAction.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState + +sealed interface SelectAction : PlaceMapAction { + data class OnPlaceClick( + val placeId: Long, + ) : SelectAction + + data class OnPlacePreviewClick( + val place: LoadState, + ) : SelectAction + + data object UnSelectPlace : SelectAction + + data class ExceededMaxLength( + val isExceededMaxLength: Boolean, + ) : SelectAction + + data class OnTimeTagClick( + val timeTag: TimeTag, + ) : SelectAction + + data object OnBackPress : SelectAction +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt new file mode 100644 index 0000000..12c5faf --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel + +sealed interface MapControlEvent { + data object InitMap : MapControlEvent + + data class InitMapManager( + val initialMapSetting: InitialMapSettingUiModel, + ) : MapControlEvent + + data object BackToInitialPosition : MapControlEvent + + data class SetMarkerByTimeTag( + val placeGeographies: List, + val selectedTimeTag: LoadState, + val isInitial: Boolean, + ) : MapControlEvent + + data class FilterMapByCategory( + val selectedCategories: List, + ) : MapControlEvent + + data class SelectMarker( + val placeDetail: LoadState, + ) : MapControlEvent + + data object UnselectMarker : MapControlEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt new file mode 100644 index 0000000..27580a5 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.placeMap.intent.event + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface PlaceMapEvent { + data class StartPlaceDetail( + val placeDetail: LoadState.Success, + ) : PlaceMapEvent + + data class PreloadImages( + val places: List, + ) : PlaceMapEvent + + data class ShowErrorSnackBar( + val error: LoadState.Error, + ) : PlaceMapEvent + + data class MenuItemReClicked( + val isPreviewVisible: Boolean, + ) : PlaceMapEvent + + data class MapViewDrag( + val isPreviewVisible: Boolean, + ) : PlaceMapEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/ActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/ActionHandler.kt new file mode 100644 index 0000000..f4fa7c9 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/ActionHandler.kt @@ -0,0 +1,10 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import kotlinx.coroutines.flow.StateFlow + +interface ActionHandler { + val uiState: StateFlow + val onUpdateState: ((before: STATE) -> STATE) -> Unit + + suspend operator fun invoke(action: ACTION) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt new file mode 100644 index 0000000..20de6db --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt @@ -0,0 +1,5 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +interface EventHandler { + suspend operator fun invoke(event: EVENT) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt new file mode 100644 index 0000000..fd7e592 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt @@ -0,0 +1,139 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.CachedPlaceByTimeTag +import com.daedan.festabook.di.placeMapHandler.CachedPlaces +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.PlaceCategory +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class FilterActionHandler( + override val uiState: StateFlow, + override val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, + private val _mapControlUiEvent: Channel, + private val logger: DefaultFirebaseLogger, + private val onUpdateCachedPlace: (List) -> Unit, + @param:CachedPlaces private val cachedPlaces: StateFlow>, + @param:CachedPlaceByTimeTag private val cachedPlaceByTimeTag: StateFlow>, +) : ActionHandler { + override suspend operator fun invoke(action: FilterAction) { + when (action) { + is FilterAction.OnCategoryClick -> { + uiState.await> { it.places } + unselectPlace() + updatePlacesByCategories(action.categories.toList()) + + onUpdateState.invoke { + it.copy(selectedCategories = action.categories) + } + + _mapControlUiEvent.send(MapControlEvent.FilterMapByCategory(action.categories.toList())) + + logger.log( + PlaceCategoryClick( + baseLogData = logger.getBaseLogData(), + currentCategories = action.categories.joinToString(",") { it.toString() }, + ), + ) + } + + is FilterAction.OnPlaceLoad -> { + val selectedTimeTag = + uiState + .map { it.selectedTimeTag } + .distinctUntilChanged() + .first() + + when (selectedTimeTag) { + is LoadState.Success -> { + updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) + } + + is LoadState.Empty -> { + updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + _mapControlUiEvent.trySend(MapControlEvent.UnselectMarker) + } + + fun updatePlacesByTimeTag(timeTagId: Long) { + val filteredPlaces = + if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + cachedPlaces.value + } else { + filterPlacesByTimeTag(timeTagId) + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + onUpdateCachedPlace(filteredPlaces) + } + + private fun updatePlacesByCategories(category: List) { + if (category.isEmpty()) { + clearPlacesFilter() + return + } + + val secondaryCategories = + PlaceCategory.SECONDARY_CATEGORIES.map { + it.toUiModel() + } + val primaryCategoriesSelected = category.any { it !in secondaryCategories } + + if (!primaryCategoriesSelected) { + clearPlacesFilter() + return + } + + val filteredPlaces = + cachedPlaceByTimeTag.value + .filter { place -> + place.category in category + } + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(filteredPlaces)) + } + } + + private fun filterPlacesByTimeTag(timeTagId: Long): List { + val filteredPlaces = + cachedPlaces.value.filter { place -> + place.timeTagId.contains(timeTagId) + } + return filteredPlaces + } + + private fun clearPlacesFilter() { + onUpdateState.invoke { + it.copy(places = ListLoadState.Success(cachedPlaceByTimeTag.value)) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt new file mode 100644 index 0000000..b5c276a --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt @@ -0,0 +1,126 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.mapManager.MapManagerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.MapDelegate +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager +import com.naver.maps.map.LocationSource +import dev.zacsweers.metro.createGraphFactory + +class MapControlEventHandler( + private val initialPadding: Int, + private val logger: DefaultFirebaseLogger, + private val locationSource: LocationSource, + private val viewModel: PlaceMapViewModel, + private val mapDelegate: MapDelegate, + private val mapManagerDelegate: MapManagerDelegate, +) : EventHandler { + private val uiState get() = viewModel.uiState.value + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: MapControlEvent) { + when (event) { + is MapControlEvent.InitMap -> { + val naverMap = mapDelegate.await() + naverMap.addOnLocationChangeListener { + logger.log( + CurrentLocationChecked( + baseLogData = logger.getBaseLogData(), + ), + ) + } + naverMap.locationSource = locationSource + } + + is MapControlEvent.InitMapManager -> { + val naverMap = mapDelegate.await() + if (mapManager == null) { + val graph = + createGraphFactory().create( + naverMap, + event.initialMapSetting, + viewModel, + initialPadding, + ) + mapManagerDelegate.init(graph.mapManager) + mapManager?.setupBackToInitialPosition { isExceededMaxLength -> + viewModel.onPlaceMapAction( + SelectAction.ExceededMaxLength(isExceededMaxLength), + ) + } + } + } + + is MapControlEvent.BackToInitialPosition -> { + mapManager?.moveToPosition() + } + + is MapControlEvent.SetMarkerByTimeTag -> { + if (event.isInitial) { + mapManager?.setupMarker(event.placeGeographies) + } + + when (val selectedTimeTag = event.selectedTimeTag) { + is LoadState.Success -> { + mapManager?.filterMarkersByTimeTag( + selectedTimeTag.value.timeTagId, + ) + } + + is LoadState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + + is MapControlEvent.FilterMapByCategory -> { + val selectedCategories = event.selectedCategories + if (selectedCategories.isEmpty()) { + mapManager?.clearFilter() + } else { + mapManager?.filterMarkersByCategories(selectedCategories) + } + } + + is MapControlEvent.SelectMarker -> { + when (val place = event.placeDetail) { + is LoadState.Success -> { + mapManager?.selectMarker(place.value.place.id) + + val currentTimeTag = uiState.selectedTimeTag + val timeTagName = + if (currentTimeTag is LoadState.Success) { + currentTimeTag.value.name + } else { + "undefined" + } + logger.log( + PlaceMarkerClick( + baseLogData = logger.getBaseLogData(), + placeId = place.value.place.id, + timeTagName = timeTagName, + category = place.value.place.category.name, + ), + ) + } + + else -> Unit + } + } + + is MapControlEvent.UnselectMarker -> { + mapManager?.unselectMarker() + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt new file mode 100644 index 0000000..d49ecb0 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt @@ -0,0 +1,61 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class MapEventActionHandler( + override val uiState: StateFlow, + override val onUpdateState: ((before: PlaceMapUiState) -> PlaceMapUiState) -> Unit, + private val _mapControlUiEvent: Channel, + private val _placeMapUiEvent: Channel, + private val logger: DefaultFirebaseLogger, +) : ActionHandler { + override suspend operator fun invoke(action: MapEventAction) { + when (action) { + is MapEventAction.OnMapReady -> { + _mapControlUiEvent.send(MapControlEvent.InitMap) + val setting = + uiState.await> { it.initialMapSetting } + _mapControlUiEvent.send(MapControlEvent.InitMapManager(setting.value)) + } + + is MapEventAction.OnPlaceLoadFinish -> + _placeMapUiEvent.send( + PlaceMapEvent.PreloadImages( + action.places, + ), + ) + + is MapEventAction.OnBackToInitialPositionClick -> { + logger.log( + PlaceBackToSchoolClick( + baseLogData = logger.getBaseLogData(), + ), + ) + _mapControlUiEvent.send(MapControlEvent.BackToInitialPosition) + } + + is MapEventAction.OnMapDrag -> { + _placeMapUiEvent.send( + PlaceMapEvent.MapViewDrag( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt new file mode 100644 index 0000000..d465339 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt @@ -0,0 +1,57 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.state.MapManagerDelegate +import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class PlaceMapEventHandler( + private val mapManagerDelegate: MapManagerDelegate, + private val bottomSheetState: PlaceListBottomSheetState, + private val viewModel: PlaceMapViewModel, + private val logger: DefaultFirebaseLogger, + // 안드로이드 종속적인 액션은 외부에서 주입 + // TODO Compose로 전환 시, 콜백이 아닌 Compose State 주입 + private val onPreloadImages: (PlaceMapEvent.PreloadImages) -> Unit, + private val onStartPlaceDetail: (PlaceMapEvent.StartPlaceDetail) -> Unit, + private val onShowErrorSnackBar: (PlaceMapEvent.ShowErrorSnackBar) -> Unit, +) : EventHandler { + private val mapManager: MapManager? get() = mapManagerDelegate.value + + override suspend operator fun invoke(event: PlaceMapEvent) { + when (event) { + is PlaceMapEvent.PreloadImages -> { + onPreloadImages(event) + } + + is PlaceMapEvent.MenuItemReClicked -> { + mapManager?.moveToPosition() + if (!event.isPreviewVisible) return + viewModel.onPlaceMapAction(SelectAction.UnSelectPlace) + logger.log( + PlaceMapButtonReClick( + baseLogData = logger.getBaseLogData(), + ), + ) + } + + is PlaceMapEvent.StartPlaceDetail -> { + onStartPlaceDetail(event) + } + + is PlaceMapEvent.ShowErrorSnackBar -> { + onShowErrorSnackBar(event) + } + + is PlaceMapEvent.MapViewDrag -> { + if (event.isPreviewVisible) return + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt new file mode 100644 index 0000000..406cfeb --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt @@ -0,0 +1,145 @@ +package com.daedan.festabook.presentation.placeMap.intent.handler + +import com.daedan.festabook.di.placeMapHandler.PlaceMapViewModelScope +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.intent.state.await +import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick +import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Inject +@ContributesBinding(PlaceMapViewModelScope::class) +class SelectActionHandler( + override val uiState: StateFlow, + override val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, + private val filterActionHandler: FilterActionHandler, + private val _placeMapUiEvent: Channel, + private val _mapControlUiEvent: Channel, + private val logger: DefaultFirebaseLogger, + private val placeDetailRepository: PlaceDetailRepository, + private val scope: CoroutineScope, +) : ActionHandler { + override suspend operator fun invoke(action: SelectAction) { + when (action) { + is SelectAction.OnPlaceClick -> { + selectPlace(action.placeId) + } + + is SelectAction.UnSelectPlace -> { + unselectPlace() + } + + is SelectAction.ExceededMaxLength -> { + onUpdateState.invoke { + it.copy( + isExceededMaxLength = action.isExceededMaxLength, + ) + } + } + + is SelectAction.OnTimeTagClick -> { + onDaySelected(action.timeTag) + filterActionHandler.updatePlacesByTimeTag(action.timeTag.timeTagId) + logger.log( + PlaceTimeTagSelected( + baseLogData = logger.getBaseLogData(), + timeTagName = action.timeTag.name, + ), + ) + } + + is SelectAction.OnPlacePreviewClick -> { + val selectedTimeTag = uiState.value.selectedTimeTag + val selectedPlace = action.place + if (selectedPlace is LoadState.Success && + selectedTimeTag is LoadState.Success + ) { + _placeMapUiEvent.send(PlaceMapEvent.StartPlaceDetail(action.place)) + logger.log( + PlacePreviewClick( + baseLogData = logger.getBaseLogData(), + placeName = + selectedPlace.value.place.title + ?: "undefined", + timeTag = selectedTimeTag.value.name, + category = selectedPlace.value.place.category.name, + ), + ) + } + } + + is SelectAction.OnBackPress -> { + unselectPlace() + } + } + } + + private fun selectPlace(placeId: Long) { + scope.launch { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Loading) } + placeDetailRepository + .getPlaceDetail(placeId = placeId) + .onSuccess { item -> + val newSelectedPlace = LoadState.Success(item.toUiModel()) + + onUpdateState.invoke { + it.copy(selectedPlace = newSelectedPlace) + } + _mapControlUiEvent.send(MapControlEvent.SelectMarker(newSelectedPlace)) + val selectedTimeTag = uiState.value.selectedTimeTag + val timeTagName = + if (selectedTimeTag is LoadState.Success) selectedTimeTag.value.name else "undefined" + logger.log( + PlaceItemClick( + baseLogData = logger.getBaseLogData(), + placeId = placeId, + timeTagName = timeTagName, + category = item.place.category.name, + ), + ) + }.onFailure { item -> + onUpdateState.invoke { + it.copy(selectedPlace = LoadState.Error(item)) + } + } + } + } + + private fun unselectPlace() { + onUpdateState.invoke { it.copy(selectedPlace = LoadState.Empty) } + _mapControlUiEvent.trySend(MapControlEvent.UnselectMarker) + } + + private fun onDaySelected(item: TimeTag) { + unselectPlace() + onUpdateState.invoke { + it.copy(selectedTimeTag = LoadState.Success(item)) + } + scope.launch { + val placeGeographies = + uiState.await>> { it.placeGeographies } + _mapControlUiEvent.send( + MapControlEvent.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = LoadState.Success(item), + isInitial = false, + ), + ) + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt new file mode 100644 index 0000000..1c305b1 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface ListLoadState { + data object Loading : ListLoadState + + data class Success( + val value: T, + ) : ListLoadState + + data class PlaceLoaded( + val value: List, + ) : ListLoadState> + + data class Error( + val throwable: Throwable, + ) : ListLoadState +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt new file mode 100644 index 0000000..a29a908 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt @@ -0,0 +1,20 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel + +sealed interface LoadState { + data object Loading : LoadState + + data object Empty : LoadState + + data class Success( + val value: T, + ) : LoadState + + data class Error( + val throwable: Throwable, + ) : LoadState +} + +val LoadState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.Companion.SECONDARY_CATEGORIES diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt new file mode 100644 index 0000000..051f4b9 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.naver.maps.map.NaverMap +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MapDelegate { + var value: NaverMap? by mutableStateOf(null) + private set + + fun initMap(map: NaverMap) { + value = map + } + + suspend fun await(timeout: Duration = 3.seconds): NaverMap = + withTimeout(timeout) { + snapshotFlow { value } + .distinctUntilChanged() + .filterNotNull() + .first() + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt new file mode 100644 index 0000000..947ee52 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager + +class MapManagerDelegate { + var value: MapManager? by mutableStateOf(null) + private set + + fun init(manager: MapManager) { + value = manager + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt new file mode 100644 index 0000000..95b9d65 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt @@ -0,0 +1,38 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +data class PlaceMapUiState( + val initialMapSetting: LoadState = LoadState.Loading, + val placeGeographies: LoadState> = LoadState.Loading, + val timeTags: LoadState> = LoadState.Empty, + val selectedTimeTag: LoadState = LoadState.Empty, + val selectedPlace: LoadState = LoadState.Empty, + val places: ListLoadState> = ListLoadState.Loading, + val isExceededMaxLength: Boolean = false, + val selectedCategories: Set = emptySet(), + val initialCategories: List = PlaceCategoryUiModel.entries, +) { + val isPlacePreviewVisible: Boolean = + (selectedPlace is LoadState.Success && !selectedPlace.isSecondary) + + val isPlaceSecondaryPreviewVisible: Boolean = + (selectedPlace is LoadState.Success && selectedPlace.isSecondary) + + val hasAnyError: LoadState<*>? + get() = + listOf( + initialMapSetting, + placeGeographies, + timeTags, + selectedTimeTag, + selectedPlace, + if (places is ListLoadState.Error) LoadState.Error(places.throwable) else LoadState.Empty, + ).filterIsInstance() + .firstOrNull() +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt new file mode 100644 index 0000000..74cdbf2 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt @@ -0,0 +1,31 @@ +package com.daedan.festabook.presentation.placeMap.intent.state + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +suspend inline fun StateFlow.await( + timeout: Duration = 3.seconds, + onTimeout: (Throwable) -> Unit = {}, + crossinline selector: (PlaceMapUiState) -> Any?, +): R = + try { + withTimeout(timeout) { + this@await + .map { selector(it) } + .distinctUntilChanged() + .filterIsInstance() + .first() + } + } catch (e: TimeoutCancellationException) { + onTimeout(e) + throw e + } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt similarity index 80% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt index d7c1de7..534af28 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt similarity index 57% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt index 2e902eb..fb07386 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/MapClickListenerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt @@ -1,5 +1,7 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import timber.log.Timber @@ -11,12 +13,14 @@ class MapClickListenerImpl( category: PlaceCategoryUiModel, ): Boolean { Timber.d("Marker CLick : placeID: $placeId categoty: $category") - viewModel.selectPlace(placeId) + viewModel.onPlaceMapAction( + SelectAction.OnPlaceClick(placeId), + ) return true } override fun onMapClickListener() { Timber.d("Map CLick") - viewModel.unselectPlace() + viewModel.onPlaceMapAction(SelectAction.UnSelectPlace) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt similarity index 61% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt index 6f8c2a5..a953f3e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/OnCameraChangeListener.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/OnCameraChangeListener.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener fun interface OnCameraChangeListener { fun onCameraChanged(isExceededMaxLength: Boolean) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt index 5ff9986..426f99c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapCameraManager.kt @@ -1,6 +1,6 @@ package com.daedan.festabook.presentation.placeMap.mapManager -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.naver.maps.geometry.LatLng interface MapCameraManager { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt index 3a9b73f..6b81594 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/MapManager.kt @@ -4,7 +4,7 @@ import androidx.core.content.ContextCompat import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.presentation.common.toPx -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt index a55ca39..92807e2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapCameraManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.OnCameraChangeListener +import com.daedan.festabook.presentation.placeMap.listener.OnCameraChangeListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel import com.daedan.festabook.presentation.placeMap.model.toLatLng diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt index 1283ecb..1843568 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapFilterManagerImpl.kt @@ -64,7 +64,12 @@ class MapFilterManagerImpl( override fun clearFilter() { markers.forEach { marker -> val place = marker.tag as? PlaceCoordinateUiModel ?: return@forEach - marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + // 타임태그가 없다면, 타임태그 검사 생략 + if (selectedTimeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + marker.isVisible = true + } else { + marker.isVisible = place.timeTagIds.contains(selectedTimeTagId) + } val isSelectedMarker = marker == selectedMarker diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt index 8a7e2d1..597fd4c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/mapManager/internal/MapMarkerManagerImpl.kt @@ -1,7 +1,7 @@ package com.daedan.festabook.presentation.placeMap.mapManager.internal import com.daedan.festabook.di.mapManager.PlaceMapScope -import com.daedan.festabook.presentation.placeMap.MapClickListener +import com.daedan.festabook.presentation.placeMap.listener.MapClickListener import com.daedan.festabook.presentation.placeMap.mapManager.MapCameraManager import com.daedan.festabook.presentation.placeMap.mapManager.MapMarkerManager import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel 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 deleted file mode 100644 index 561e744..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -sealed interface PlaceListUiState { - class Loading : PlaceListUiState - - data class Success( - val value: T, - ) : PlaceListUiState - - data class PlaceLoaded( - val value: List, - ) : PlaceListUiState> - - data class Error( - val throwable: Throwable, - ) : PlaceListUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt deleted file mode 100644 index fbb9bc6..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel - -sealed interface SelectedPlaceUiState { - data object Loading : SelectedPlaceUiState - - data object Empty : SelectedPlaceUiState - - data class Success( - val value: PlaceDetailUiModel, - ) : SelectedPlaceUiState { - val isSecondary = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES - } - - data class Error( - val throwable: Throwable, - ) : SelectedPlaceUiState -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt deleted file mode 100644 index edf6650..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeCategory - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.asFlow -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceCategoryBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceCategoryFragment::class) -@Inject -class PlaceCategoryFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment() { - override val layoutId: Int = R.layout.fragment_place_category - - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View = - ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FestabookTheme { - val initialCategories = PlaceCategoryUiModel.entries - // StateFlow로 변경 시 asFlow 제거 예정 - val timeTagChanged = - viewModel.selectedTimeTag - .asFlow() - .collectAsStateWithLifecycle(viewLifecycleOwner) - var selectedCategoriesState by remember(timeTagChanged.value) { - mutableStateOf( - emptySet(), - ) - } - - PlaceCategoryScreen( - initialCategories = initialCategories, - selectedCategories = selectedCategoriesState, - onCategoryClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - viewModel.unselectPlace() - viewModel.setSelectedCategories(selectedCategories.toList()) - appGraph.defaultFirebaseLogger.log( - PlaceCategoryClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - currentCategories = selectedCategories.joinToString(",") { it.toString() }, - ), - ) - }, - onDisplayAllClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - viewModel.unselectPlace() - viewModel.setSelectedCategories(initialCategories) - }, - ) - } - } - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt deleted file mode 100644 index cf57845..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewBinding -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.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.daedan.festabook.presentation.theme.festabookSpacing -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewFragment::class) -@Inject -class PlaceDetailPreviewFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - super.onCreateView(inflater, container, savedInstanceState) - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - FestabookTheme { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success - - LaunchedEffect(placeDetailUiState) { - backPressedCallback.isEnabled = true - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - PlaceDetailPreviewScreen( - placeUiState = placeDetailUiState, - visible = visible, - modifier = - Modifier - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - onClick = { selectedPlace -> - if (selectedPlace !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewScreen - startPlaceDetailActivity(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = - selectedPlace.value.place.title - ?: "undefined", - timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", - category = selectedPlace.value.place.category.name, - ), - ) - }, - onError = { selectedPlace -> - showErrorSnackBar(selectedPlace.throwable) - }, - onEmpty = { - backPressedCallback.isEnabled = false - }, - ) - } - } - } - } - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpBackPressedCallback() - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } - - private fun setUpBackPressedCallback() { - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - backPressedCallback, - ) - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - startActivity(PlaceDetailActivity.newIntent(requireContext(), placeDetail)) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt deleted file mode 100644 index 48edaeb..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeDetailPreview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.daedan.festabook.R -import com.daedan.festabook.databinding.FragmentPlaceDetailPreviewSecondaryBinding -import com.daedan.festabook.di.appGraph -import com.daedan.festabook.di.fragment.FragmentKey -import com.daedan.festabook.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen -import com.daedan.festabook.presentation.theme.FestabookTheme -import com.daedan.festabook.presentation.theme.festabookSpacing -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceDetailPreviewSecondaryFragment::class) -@Inject -class PlaceDetailPreviewSecondaryFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnMenuItemReClickListener { - override val layoutId: Int = R.layout.fragment_place_detail_preview_secondary - - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val backPressedCallback = - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - viewModel.unselectPlace() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success - - LaunchedEffect(placeDetailUiState) { - backPressedCallback.isEnabled = true - } - - FestabookTheme { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - PlaceDetailPreviewSecondaryScreen( - visible = visible, - placeUiState = placeDetailUiState, - modifier = - Modifier - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - onError = { - showErrorSnackBar(it.throwable) - }, - onEmpty = { - backPressedCallback.isEnabled = false - }, - onClick = { - if (it !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen - appGraph.defaultFirebaseLogger.log( - PlacePreviewClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeName = it.value.place.title ?: "undefined", - timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", - category = it.value.place.category.name, - ), - ) - }, - ) - } - } - } - } - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt deleted file mode 100644 index 9ab3aaf..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel - -fun interface OnPlaceClickListener { - fun onPlaceClicked(place: PlaceUiModel) -} 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 deleted file mode 100644 index 779b277..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.placeList - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -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 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.presentation.common.BaseFragment -import com.daedan.festabook.presentation.common.OnMenuItemReClickListener -import com.daedan.festabook.presentation.common.showErrorSnackBar -import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -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.PlaceUiModel -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 -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.binding -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.withTimeout -import timber.log.Timber - -@ContributesIntoMap(scope = AppScope::class, binding = binding()) -@FragmentKey(PlaceListFragment::class) -@Inject -class PlaceListFragment( - override val defaultViewModelProviderFactory: ViewModelProvider.Factory, -) : BaseFragment(), - OnPlaceClickListener, - OnMenuItemReClickListener, - OnMapReadyCallback { - override val layoutId: Int = R.layout.fragment_place_list - private val viewModel: PlaceMapViewModel by viewModels({ requireParentFragment() }) - private val childViewModel: PlaceListViewModel by viewModels() - - // 기존 Fragment와의 상호 운용성을 위한 임시 Flow입니다. - // Fragment -> PlaceMapScreen으로 통합 시, 제거할 예정입니다. - private val mapFlow: MutableStateFlow = MutableStateFlow(null) - - 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) - } - } - - 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) - setUpObserver() - } - - override fun onPlaceClicked(place: PlaceUiModel) { - Timber.d("onPlaceClicked: $place") - startPlaceDetailActivity(place) - appGraph.defaultFirebaseLogger.log( - PlaceItemClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeId = place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", - category = place.category.name, - ), - ) - } - - override fun onMenuItemReClick() { - if (binding.root.isGone || !isResumed || view == null) return - lifecycleScope.launch { - viewModel.onMapViewClick() - } - appGraph.defaultFirebaseLogger.log( - PlaceMapButtonReClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - } - - override fun onMapReady(naverMap: NaverMap) { - lifecycleScope.launch { - mapFlow.value = naverMap - } - } - - private fun setUpObserver() { - viewModel.navigateToDetail.observe(viewLifecycleOwner) { selectedPlace -> - startPlaceDetailActivity(selectedPlace) - } - - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - childViewModel.clearPlacesFilter() - } else { - childViewModel.updatePlacesByCategories(selectedCategories) - } - } - } - - private fun startPlaceDetailActivity(place: PlaceUiModel) { - viewModel.selectPlace(place.id) - } - - private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { - Timber.d("start detail activity") - val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) - startActivity(intent) - } - - // OOM 주의 !! 추후 페이징 처리 및 chunk 단위로 나눠서 로드합니다 - private fun preloadImages( - context: Context, - places: List, - maxSize: Int = 20, - ) { - val imageLoader = ImageLoader(context) - val deferredList = mutableListOf>() - val defaultImage = - ContextCompat - .getDrawable( - requireContext(), - R.drawable.img_fallback, - )?.asImage() - - lifecycleScope.launch(Dispatchers.IO) { - places - .take(maxSize) - .filterNotNull() - .forEach { place -> - val deferred = - async { - val request = - ImageRequest - .Builder(context) - .data(place.imageUrl) - .error { - defaultImage - }.fallback { - defaultImage - }.build() - - runCatching { - withTimeout(2000) { - imageLoader.execute(request) - } - }.onFailure { - imageLoader.shutdown() - }.getOrNull() - } - deferredList.add(deferred) - } - deferredList.awaitAll() - } - } -} 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 deleted file mode 100644 index 184101a..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceListRepository -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 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) -@ViewModelKey(PlaceListViewModel::class) -@Inject -class PlaceListViewModel( - private val placeListRepository: PlaceListRepository, -) : ViewModel() { - private var cachedPlaces = listOf() - private var cachedPlaceByTimeTag: List = emptyList() - - private val _places: MutableLiveData>> = - MutableLiveData(PlaceListUiState.Loading()) - val places: LiveData>> = _places - - val placesFlow: StateFlow>> = - _places.asFlow().stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = PlaceListUiState.Loading(), - ) - - init { - loadAllPlaces() - } - - fun updatePlacesByCategories(category: List) { - val secondaryCategories = - PlaceCategory.SECONDARY_CATEGORIES.map { - it.toUiModel() - } - val primaryCategoriesSelected = category.any { it !in secondaryCategories } - - if (!primaryCategoriesSelected) { - clearPlacesFilter() - return - } - val filteredPlaces = - cachedPlaceByTimeTag - .filter { place -> - place.category in category - } - _places.value = PlaceListUiState.Success(filteredPlaces) - } - - private fun filterPlacesByTimeTag(timeTagId: Long): List { - val filteredPlaces = - cachedPlaces.filter { place -> - place.timeTagId.contains(timeTagId) - } - return filteredPlaces - } - - fun updatePlacesByTimeTag(timeTagId: Long) { - val filteredPlaces = - if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { - cachedPlaces - } else { - filterPlacesByTimeTag(timeTagId) - } - - _places.value = PlaceListUiState.Success(filteredPlaces) - cachedPlaceByTimeTag = filteredPlaces - } - - fun clearPlacesFilter() { - _places.value = PlaceListUiState.Success(cachedPlaceByTimeTag) - } - - private fun loadAllPlaces() { - viewModelScope.launch { - val result = placeListRepository.getPlaces() - result - .onSuccess { places -> - val placeUiModels = places.map { it.toUiModel() } - cachedPlaces = placeUiModels - _places.value = PlaceListUiState.PlaceLoaded(placeUiModels) - }.onFailure { - _places.value = PlaceListUiState.Error(it) - } - } - } -} diff --git a/app/src/main/res/layout/activity_place_detail.xml b/app/src/main/res/layout/activity_place_detail.xml index 48cf719..6a3835a 100644 --- a/app/src/main/res/layout/activity_place_detail.xml +++ b/app/src/main/res/layout/activity_place_detail.xml @@ -64,7 +64,7 @@ app:ci_animator="@animator/scale_with_alpha" app:layout_constraintBottom_toBottomOf="@id/vp_place_images" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"/> + app:layout_constraintEnd_toEndOf="parent" /> + app:layout_constraintTop_toTopOf="@id/vp_place_images" /> - + app:layout_constraintTop_toBottomOf="@id/vp_place_images" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview.xml b/app/src/main/res/layout/fragment_place_detail_preview.xml deleted file mode 100644 index 52844bb..0000000 --- a/app/src/main/res/layout/fragment_place_detail_preview.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml b/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml deleted file mode 100644 index c25892c..0000000 --- a/app/src/main/res/layout/fragment_place_detail_preview_secondary.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_place_list.xml b/app/src/main/res/layout/fragment_place_list.xml deleted file mode 100644 index f79db6c..0000000 --- a/app/src/main/res/layout/fragment_place_list.xml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_place_list.xml b/app/src/main/res/layout/item_place_list.xml deleted file mode 100644 index 13f3cda..0000000 --- a/app/src/main/res/layout/item_place_list.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_place_list_skeleton.xml b/app/src/main/res/layout/item_place_list_skeleton.xml deleted file mode 100644 index 2af0610..0000000 --- a/app/src/main/res/layout/item_place_list_skeleton.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt new file mode 100644 index 0000000..16f9d7b --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + withTimeout(3000) { + flow.first() + } + } + advanceUntilIdle() + return event +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeMultipleEvent( + flow: Flow, + result: MutableList, +) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + flow + .timeout(3.seconds) + .collect { + result.add(it) + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt index 6ef17fc..dd77ddb 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailTestFixture.kt @@ -6,7 +6,7 @@ import com.daedan.festabook.domain.model.PlaceDetail import com.daedan.festabook.domain.model.PlaceDetailImage import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.news.FAKE_NOTICES -import com.daedan.festabook.placeList.FAKE_PLACES +import com.daedan.festabook.placeMap.FAKE_PLACES import java.time.LocalTime val FAKE_PLACE_DETAIL = diff --git a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt index 0f64bfb..c072a46 100644 --- a/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeDetail/PlaceDetailViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.getOrAwaitValue import com.daedan.festabook.news.FAKE_NOTICES -import com.daedan.festabook.placeList.FAKE_PLACES +import com.daedan.festabook.placeMap.FAKE_PLACES import com.daedan.festabook.presentation.news.notice.model.toUiModel import com.daedan.festabook.presentation.placeDetail.PlaceDetailViewModel import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiState diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt deleted file mode 100644 index d605eb1..0000000 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.daedan.festabook.placeList - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.TimeTag -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.toUiModel -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaceListViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private lateinit var placeListRepository: PlaceListRepository - private lateinit var placeListViewModel: PlaceListViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - placeListRepository = mockk() - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - placeListViewModel = - PlaceListViewModel( - placeListRepository, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `뷰모델을 생성했을 때 모든 플레이스 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - - // when - placeListViewModel = PlaceListViewModel(placeListRepository) - advanceUntilIdle() - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - coVerify { placeListRepository.getPlaces() } - assertThat(actual).isEqualTo(PlaceListUiState.PlaceLoaded(expected)) - } - - @Test - fun `선택된 카테고리를 전달하면 해당 카테고리의 플레이스만 필터링 할 수 있다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - - // when - placeListViewModel.updatePlacesByCategories(targetCategories) - - // then - val expected = - FAKE_PLACES - .filter { it.category.toUiModel() in targetCategories } - .map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `선택된 카테고리가 부스, 주점, 푸드트럭에 해당되지 않을 때 전체 목록을 불러온다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - - // when - placeListViewModel.updatePlacesByCategories(targetCategories) - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `필터링을 해제하면 전체 목록을 반환한다`() = - runTest { - // given - val targetCategories = - listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMPTY.timeTagId) - placeListViewModel.updatePlacesByCategories(targetCategories) - - // when - placeListViewModel.clearPlacesFilter() - - // then - val expected = FAKE_PLACES.map { it.toUiModel() } - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `타임 태그를 기준으로 필터링 할 수 있다`() = - runTest { - // given - val expected = - listOf( - FAKE_PLACES.first().toUiModel(), - ) - - // when - placeListViewModel.updatePlacesByTimeTag(1) - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `타임 태그가 없을 때 전체 목록을 반환한다`() = - runTest { - // given - val expected = FAKE_PLACES.map { it.toUiModel() } - val emptyTimeTag = TimeTag.EMPTY - - // when - placeListViewModel.updatePlacesByTimeTag(emptyTimeTag.timeTagId) - - // then - val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } -} diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt deleted file mode 100644 index 2f8ba67..0000000 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ /dev/null @@ -1,329 +0,0 @@ -package com.daedan.festabook.placeList - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.domain.repository.PlaceDetailRepository -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.getOrAwaitValue -import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL -import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel -import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -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.SelectedPlaceUiState -import com.daedan.festabook.presentation.placeMap.model.toUiModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaceMapViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private lateinit var placeListRepository: PlaceListRepository - private lateinit var placeDetailRepository: PlaceDetailRepository - private lateinit var placeMapViewModel: PlaceMapViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - placeListRepository = mockk() - placeDetailRepository = mockk() - coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - coEvery { placeListRepository.getTimeTags() } returns - Result.success( - listOf( - FAKE_TIME_TAG, - ), - ) - placeMapViewModel = - PlaceMapViewModel( - placeListRepository, - placeDetailRepository, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `뷰모델을 생성했을 때 전체 타임 태그와 선택된 타임 태그를 불러올 수 있다`() = - runTest { - // given - when - placeMapViewModel = - PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val actualAllTimeTag = placeMapViewModel.timeTags.value - val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actualAllTimeTag).isEqualTo(listOf(FAKE_TIME_TAG)) - assertThat(actualSelectedTimeTag).isEqualTo(FAKE_TIME_TAG) - } - - @Test - fun `뷰모델을 생성했을 때 타임 태그가 없다면 빈 리스트와 Empty타임 태그를 불러온다`() = - runTest { - // given - coEvery { - placeListRepository.getTimeTags() - } returns Result.success(emptyList()) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val actualAllTimeTag = placeMapViewModel.timeTags.value - val actualSelectedTimeTag = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actualAllTimeTag).isEqualTo(emptyList()) - assertThat(actualSelectedTimeTag).isEqualTo(TimeTag.EMPTY) - } - - @Test - fun `뷰모델을 생성했을 때 모든 플레이스의 지도 좌표 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getPlaceGeographies() } returns - Result.success( - FAKE_PLACE_GEOGRAPHIES, - ) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } - val actual = placeMapViewModel.placeGeographies.getOrAwaitValue() - coVerify { placeListRepository.getPlaceGeographies() } - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `뷰모델을 생성했을 때 초기 학교 지리 정보를 불러올 수 있다`() = - runTest { - // given - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() - val actual = placeMapViewModel.initialMapSetting.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) - } - - @Test - fun `뷰모델을 생성했을 때 정보 로드에 실패하면 독립적으로 에러 상태를 표시한다`() = - runTest { - // given - val exception = Throwable("테스트") - coEvery { placeListRepository.getPlaces() } returns Result.failure(exception) - coEvery { placeListRepository.getOrganizationGeography() } returns - Result.success( - FAKE_ORGANIZATION_GEOGRAPHY, - ) - coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) - - // when - placeMapViewModel = PlaceMapViewModel(placeListRepository, placeDetailRepository) - advanceUntilIdle() - - // then - val expected2 = - PlaceListUiState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) - val actual2 = placeMapViewModel.initialMapSetting.getOrAwaitValue() - - val expected3 = PlaceListUiState.Error(exception) - val actual3 = placeMapViewModel.placeGeographies.getOrAwaitValue() - - assertThat(actual2).isEqualTo(expected2) - assertThat(actual3).isEqualTo(expected3) - } - - @Test - fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_PLACE_DETAIL, - ) - - // when - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // then - coVerify { placeDetailRepository.getPlaceDetail(1) } - - val expected = SelectedPlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `카테고리가 기타시설일 떄에도 플레이스 상세를 선택할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_ETC_PLACE_DETAIL, - ) - - // when - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // then - val expected = SelectedPlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `플레이스 상세 선택을 해제할 수 있다`() = - runTest { - // given - coEvery { placeDetailRepository.getPlaceDetail(1) } returns - Result.success( - FAKE_PLACE_DETAIL, - ) - placeMapViewModel.selectPlace(1) - advanceUntilIdle() - - // when - placeMapViewModel.unselectPlace() - advanceUntilIdle() - - // then - val expected = SelectedPlaceUiState.Empty - val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = - runTest { - // given - - // when - placeMapViewModel.onBackToInitialPositionClicked() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.backToInitialPositionClicked.getOrAwaitValue() - assertThat(actual).isInstanceOf(Event::class.java) - } - - @Test - fun `학교로 돌아가기 버튼이 나타나지 않는 임계값을 넣을 수 있다`() = - runTest { - // given - val isExceededMaxLength = true - - // when - placeMapViewModel.setIsExceededMaxLength(isExceededMaxLength) - - // then - val actual = placeMapViewModel.isExceededMaxLength.getOrAwaitValue() - assertThat(actual).isEqualTo(isExceededMaxLength) - } - - @Test - fun `선택된 카테고리 값을 넣을 수 있다`() = - runTest { - // given - val categories = listOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) - - // when - placeMapViewModel.setSelectedCategories(categories) - - // then - val actual = placeMapViewModel.selectedCategories.getOrAwaitValue() - assertThat(actual).isEqualTo(categories) - } - - @Test - fun `지도를 클릭했을 때 이벤트를 발생시킬수 있다`() = - runTest { - // given - val expected = Unit - - // when - placeMapViewModel.onMapViewClick() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.onMapViewClick.getOrAwaitValue() - assertThat(actual.peekContent()).isEqualTo(expected) - } - - @Test - fun `현재 플레이스를 선택 후, 플레이스 상세로 이벤트를 발생시킬 수 있다`() = - runTest { - // given - coEvery { - placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) - } returns Result.success(FAKE_PLACE_DETAIL) - val expected = FAKE_PLACE_DETAIL.toUiModel() - placeMapViewModel.selectPlace(FAKE_PLACE_DETAIL.id) - advanceUntilIdle() - - // when - placeMapViewModel.onExpandedStateReached() - advanceUntilIdle() - - // then - val actual = placeMapViewModel.navigateToDetail.value - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `타임태그가 선택되었음을 알리는 이벤트를 발생시킬 수 있다`() = - runTest { - // given - val expected = TimeTag(1, "테스트1") - - // when - placeMapViewModel.onDaySelected(expected) - advanceUntilIdle() - - // then - val actual = placeMapViewModel.selectedTimeTag.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } -} diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt similarity index 65% rename from app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt rename to app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt index c29339a..954f872 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceLIstTestFixture.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.placeList +package com.daedan.festabook.placeMap import com.daedan.festabook.domain.model.Coordinate import com.daedan.festabook.domain.model.OrganizationGeography @@ -6,6 +6,8 @@ import com.daedan.festabook.domain.model.Place import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.domain.model.PlaceGeography import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeMap.model.CoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel val FAKE_PLACES = listOf( @@ -45,6 +47,44 @@ val FAKE_PLACES = ), ) +val FAKE_PLACES_CATEGORY_FIXTURE = + listOf( + Place( + id = 1, + imageUrl = null, + category = PlaceCategory.FOOD_TRUCK, + title = "테스트 1", + description = "설명 1", + location = "위치 1", + timeTags = + listOf( + TimeTag( + timeTagId = 1, + name = "테스트1", + ), + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + Place( + id = 2, + imageUrl = null, + category = PlaceCategory.BAR, + title = "테스트 2", + description = "설명 2", + location = "위치 2", + timeTags = + listOf( + TimeTag( + timeTagId = 2, + name = "테스트2", + ), + ), + ), + ) + val FAKE_PLACE_GEOGRAPHIES = listOf( PlaceGeography( @@ -119,3 +159,20 @@ val FAKE_TIME_TAG = timeTagId = 1, name = "테스트1", ) + +val FAKE_INITIAL_MAP_SETTING = + InitialMapSettingUiModel( + zoom = 10, + initialCenter = + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + border = + listOf( + CoordinateUiModel( + latitude = 10.0, + longitude = 10.0, + ), + ), + ) diff --git a/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt new file mode 100644 index 0000000..fd5b432 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt @@ -0,0 +1,299 @@ +package com.daedan.festabook.placeMap + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceListRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaceMapViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + + private val handlerGraphFactory = mockk(relaxed = true) + private lateinit var placeListRepository: PlaceListRepository + private lateinit var placeMapViewModel: PlaceMapViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + placeListRepository = mockk(relaxed = true) + coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + coEvery { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + coEvery { placeListRepository.getTimeTags() } returns + Result.success( + listOf( + FAKE_TIME_TAG, + ), + ) + + placeMapViewModel = + PlaceMapViewModel( + placeListRepository, + handlerGraphFactory, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `뷰모델을 생성했을 때 전체 타임태그, 선택된 타임태그를 불러올 수 있다`() = + runTest { + // given - when + placeMapViewModel = + PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertThat(actualAllTimeTag).isEqualTo( + LoadState.Success( + listOf(FAKE_TIME_TAG), + ), + ) + assertThat(actualSelectedTimeTag).isEqualTo( + LoadState.Success(FAKE_TIME_TAG), + ) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getPlaces() } returns Result.success(FAKE_PLACES) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.places + coVerify { placeListRepository.getPlaces() } + assertThat(actual).isEqualTo(ListLoadState.PlaceLoaded(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 타임 태그가 없다면 빈 리스트와 Empty타임 태그를 불러온다`() = + runTest { + // given + coEvery { + placeListRepository.getTimeTags() + } returns Result.success(emptyList()) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val actualAllTimeTag = uiState.timeTags + val actualSelectedTimeTag = uiState.selectedTimeTag + assertThat(actualAllTimeTag).isEqualTo( + LoadState.Success(emptyList()), + ) + assertThat(actualSelectedTimeTag).isEqualTo( + LoadState.Empty, + ) + } + + @Test + fun `뷰모델을 생성했을 때 모든 플레이스의 지도 좌표 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getPlaceGeographies() } returns + Result.success( + FAKE_PLACE_GEOGRAPHIES, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } + val uiState = placeMapViewModel.uiState.value + val actual = uiState.placeGeographies + coVerify { placeListRepository.getPlaceGeographies() } + assertThat(actual).isEqualTo(LoadState.Success(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 초기 학교 지리 정보를 불러올 수 있다`() = + runTest { + // given + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() + val uiState = placeMapViewModel.uiState.value + val actual = uiState.initialMapSetting + assertThat(actual).isEqualTo(LoadState.Success(expected)) + } + + @Test + fun `뷰모델을 생성했을 때 정보 로드에 실패하면 독립적으로 에러 상태를 표시한다`() = + runTest { + // given + val exception = Throwable("테스트") + coEvery { placeListRepository.getPlaces() } returns Result.failure(exception) + coEvery { placeListRepository.getOrganizationGeography() } returns + Result.success( + FAKE_ORGANIZATION_GEOGRAPHY, + ) + coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(exception) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + advanceUntilIdle() + + // then + val uiState = placeMapViewModel.uiState.value + val expected2 = + LoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) + val actual2 = uiState.initialMapSetting + + val expected3 = LoadState.Error(exception) + val actual3 = uiState.placeGeographies + + assertThat(actual2).isEqualTo(expected2) + assertThat(actual3).isEqualTo(expected3) + } + + @Test + fun `특정 액션을 받으면 액션 핸들러가 호출된다`() = + runTest { + // given + val fakeHandlerGraph = mockk(relaxed = true) + coEvery { + handlerGraphFactory.create( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns fakeHandlerGraph + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + placeMapViewModel.onPlaceMapAction(SelectAction.UnSelectPlace) + placeMapViewModel.onPlaceMapAction(FilterAction.OnPlaceLoad) + placeMapViewModel.onPlaceMapAction(MapEventAction.OnMapDrag) + advanceUntilIdle() + + // then + coVerify(exactly = 1) { fakeHandlerGraph.filterActionHandler } + coVerify(exactly = 1) { fakeHandlerGraph.selectActionHandler } + coVerify(exactly = 1) { fakeHandlerGraph.mapEventActionHandler } + } + + @Test + fun `메뉴 아이템 재클릭 이벤트를 발송할 수 있다`() = + runTest { + // given + val event = observeEvent(placeMapViewModel.placeMapUiEvent) + + // when + placeMapViewModel.onMenuItemReClicked() + val result = event.await() + advanceUntilIdle() + + // then + assertThat(result).isInstanceOf(PlaceMapEvent.MenuItemReClicked::class.java) + } + + @Test + fun `LoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + coEvery { placeListRepository.getPlaceGeographies() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapUiEvent) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertThat(result).isEqualTo( + PlaceMapEvent.ShowErrorSnackBar( + LoadState.Error(throwable), + ), + ) + } + + @Test + fun `ListLoadState가 하나라도 에러가 있다면 에러 이벤트를 발송할 수 있다`() = + runTest { + // given + val throwable = Throwable() + coEvery { placeListRepository.getPlaces() } returns Result.failure(throwable) + + // when + placeMapViewModel = PlaceMapViewModel(placeListRepository, handlerGraphFactory) + val event = observeEvent(placeMapViewModel.placeMapUiEvent) + advanceUntilIdle() + + // then + val result = event.await() + advanceUntilIdle() + + assertThat(result).isEqualTo( + PlaceMapEvent.ShowErrorSnackBar( + LoadState.Error(throwable), + ), + ) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt new file mode 100644 index 0000000..832580b --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt @@ -0,0 +1,232 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_PLACES_CATEGORY_FIXTURE +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.FilterActionHandler +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.toUiModel +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FilterActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + + private lateinit var filterActionHandler: FilterActionHandler + + private lateinit var cachedPlaces: MutableStateFlow> + + private lateinit var uiState: MutableStateFlow + + private val cachedPlaceByTimeTag = + MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + cachedPlaces = MutableStateFlow(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + + filterActionHandler = + FilterActionHandler( + uiState = uiState, + _mapControlUiEvent = mapControlUiEvent, + onUpdateState = { uiState.update(it) }, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + logger = mockk(relaxed = true), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `선택된 카테고리 값을 선택하면 카테고리 필터 이벤트가 방출되고, 카테고리를 필터링 할 수 있다`() = + runTest { + // given + val categories = setOf(PlaceCategoryUiModel.BOOTH) + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.consumeAsFlow(), eventResult) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterAction.OnCategoryClick(categories)) + + // then + advanceUntilIdle() + + assertThat( + uiState.value.selectedCategories, + ).isEqualTo( + categories, + ) + + assertThat(eventResult).containsExactly( + MapControlEvent.UnselectMarker, + MapControlEvent.FilterMapByCategory(categories.toList()), + ) + assertThat(uiState.value.places).isEqualTo( + ListLoadState.Success(emptyList()), + ) + } + + @Test + fun `선택된 카테고리가 부스, 주점, 푸드트럭에 해당되지 않을 때 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.SMOKING_AREA, PlaceCategoryUiModel.TOILET) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterAction.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success( + FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }, + ) + val actual = uiState.value.places + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `기타 카테고리만 선택되었다면 전체 목록을 불러온다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.TOILET, PlaceCategoryUiModel.SMOKING_AREA) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterAction.OnCategoryClick(targetCategories)) + advanceUntilIdle() + + // then + val expected = + ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + val actual = uiState.value.places + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `필터링을 해제하면 전체 목록을 반환한다`() = + runTest { + // given + val targetCategories = + setOf(PlaceCategoryUiModel.FOOD_TRUCK, PlaceCategoryUiModel.BOOTH) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + filterActionHandler(FilterAction.OnCategoryClick(targetCategories)) + + // when + filterActionHandler(FilterAction.OnCategoryClick(emptySet())) + + // then + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `타임 태그를 기준으로 필터링 할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(FAKE_TIME_TAG.timeTagId) + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `타임 태그가 없을 때 전체 목록을 반환한다`() = + runTest { + // given + val expected = FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() } + val emptyTimeTag = TimeTag.EMPTY + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler.updatePlacesByTimeTag(emptyTimeTag.timeTagId) + advanceUntilIdle() + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } + + @Test + fun `플레이스가 로드가 완료되었을 때 선택된 타임 태그로 필터링할 수 있다`() = + runTest { + // given + val expected = + listOf( + FAKE_PLACES_CATEGORY_FIXTURE.first().toUiModel(), + ) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { + it.copy( + places = places, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + filterActionHandler(FilterAction.OnPlaceLoad) + advanceUntilIdle() + + // then + val actual = uiState.value.places + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt new file mode 100644 index 0000000..0147ac8 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt @@ -0,0 +1,158 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.observeEvent +import com.daedan.festabook.observeMultipleEvent +import com.daedan.festabook.placeMap.FAKE_INITIAL_MAP_SETTING +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.MapEventActionHandler +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MapEventActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private lateinit var mapEventActionHandler: MapEventActionHandler + + private lateinit var uiState: MutableStateFlow + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + uiState = MutableStateFlow(PlaceMapUiState()) + mapEventActionHandler = + MapEventActionHandler( + uiState = uiState, + onUpdateState = { uiState.update(it) }, + _mapControlUiEvent = mapControlUiEvent, + _placeMapUiEvent = placeMapUiEvent, + logger = mockk(relaxed = true), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapEventAction.OnBackToInitialPositionClick) + + val event = eventResult.await() + advanceUntilIdle() + + // then + assertThat(event).isEqualTo(MapControlEvent.BackToInitialPosition) + } + + @Test + fun `지도가 준비되었을 때 지도 관련 로직 초기화 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.receiveAsFlow(), eventResult) + + val initialSetting = FAKE_INITIAL_MAP_SETTING + uiState.update { + it.copy(initialMapSetting = LoadState.Success(initialSetting)) + } + + // when + mapEventActionHandler(MapEventAction.OnMapReady) + advanceUntilIdle() + + // then + assertThat(eventResult).containsExactly( + MapControlEvent.InitMap, + MapControlEvent.InitMapManager(initialSetting), + ) + } + + @Test + fun `플레이스 로딩이 완료되었을 때 프리로드 이미지 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapEventAction.OnPlaceLoadFinish(emptyList())) + + // then + val event = eventResult.await() + advanceUntilIdle() + assertThat(event).isEqualTo(PlaceMapEvent.PreloadImages(emptyList())) + } + + @Test + fun `초기 위치로 돌아갔을 때 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapEventAction.OnBackToInitialPositionClick) + val event = eventResult.await() + advanceUntilIdle() + + // then + assertThat(event).isEqualTo(MapControlEvent.BackToInitialPosition) + } + + @Test + fun `지도가 드래그 되었을 때 이벤트를 방출할 수 있다`() = + runTest { + // given + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + mapEventActionHandler(MapEventAction.OnMapDrag) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo(PlaceMapEvent.MapViewDrag(false)) + } +} diff --git a/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt new file mode 100644 index 0000000..6670cc7 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt @@ -0,0 +1,238 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.observeEvent +import com.daedan.festabook.placeDetail.FAKE_ETC_PLACE_DETAIL +import com.daedan.festabook.placeDetail.FAKE_PLACE_DETAIL +import com.daedan.festabook.placeMap.FAKE_TIME_TAG +import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.intent.action.SelectAction +import com.daedan.festabook.presentation.placeMap.intent.event.MapControlEvent +import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.intent.handler.SelectActionHandler +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.PlaceMapUiState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectActionHandlerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private lateinit var selectActionHandler: SelectActionHandler + + private lateinit var uiState: MutableStateFlow + + private lateinit var placeDetailRepository: PlaceDetailRepository + + private val mapControlUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val placeMapUiEvent: Channel = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + placeDetailRepository = mockk() + uiState = MutableStateFlow(PlaceMapUiState()) + + selectActionHandler = + SelectActionHandler( + _mapControlUiEvent = mapControlUiEvent, + _placeMapUiEvent = placeMapUiEvent, + filterActionHandler = mockk(relaxed = true), + logger = mockk(relaxed = true), + uiState = uiState, + onUpdateState = { uiState.update(it) }, + scope = CoroutineScope(testDispatcher), + placeDetailRepository = placeDetailRepository, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectAction.OnPlaceClick(1)) + advanceUntilIdle() + + // then + coVerify { placeDetailRepository.getPlaceDetail(1) } + + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlEvent.SelectMarker(expected)) + } + + @Test + fun `카테고리가 기타시설일 떄에도 플레이스 상세를 선택할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_ETC_PLACE_DETAIL, + ) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectAction.OnPlaceClick(1)) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlEvent.SelectMarker(expected)) + } + + @Test + fun `플레이스 상세 선택을 해제할 수 있다`() = + runTest { + // given + coEvery { placeDetailRepository.getPlaceDetail(1) } returns + Result.success( + FAKE_PLACE_DETAIL, + ) + selectActionHandler(SelectAction.OnPlaceClick(1)) + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + advanceUntilIdle() + + // when + selectActionHandler(SelectAction.UnSelectPlace) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + val expected = LoadState.Empty + val actual = uiState.value.selectedPlace + assertThat(actual).isEqualTo(expected) + assertThat(event).isEqualTo(MapControlEvent.UnselectMarker) + } + + @Test + fun `학교로 돌아가기 버튼이 나타나지 않는 임계값을 넣을 수 있다`() = + runTest { + // given + val isExceededMaxLength = true + + // when + selectActionHandler(SelectAction.ExceededMaxLength(isExceededMaxLength)) + advanceUntilIdle() + + // then + assertThat(uiState.value.isExceededMaxLength).isEqualTo(isExceededMaxLength) + } + + @Test + fun `현재 플레이스를 선택 후, 플레이스 상세로 이벤트를 발생시킬 수 있다`() = + runTest { + // given + coEvery { + placeDetailRepository.getPlaceDetail(FAKE_PLACE_DETAIL.id) + } returns Result.success(FAKE_PLACE_DETAIL) + + val eventResult = observeEvent(placeMapUiEvent.receiveAsFlow()) + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) + uiState.update { + it.copy( + selectedPlace = expected, + selectedTimeTag = LoadState.Success(FAKE_TIME_TAG), + ) + } + + // when + selectActionHandler( + SelectAction.OnPlacePreviewClick(expected), + ) + advanceUntilIdle() + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo( + PlaceMapEvent.StartPlaceDetail(expected), + ) + } + + @Test + fun `타임태그가 선택되었음을 알리는 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val expected = TimeTag(1, "테스트1") + + // when + selectActionHandler(SelectAction.OnTimeTagClick(expected)) + advanceUntilIdle() + + // then + val actual = uiState.value.selectedTimeTag + assertThat(actual).isEqualTo( + LoadState.Success(expected), + ) + } + + @Test + fun `뒤로가기가 클릭되었을 때 선택 해제 이벤트를 발생시킬 수 있다`() = + runTest { + // given + val eventResult = observeEvent(mapControlUiEvent.receiveAsFlow()) + + // when + selectActionHandler(SelectAction.OnBackPress) + + // then + val event = eventResult.await() + advanceUntilIdle() + + assertThat(event).isEqualTo(MapControlEvent.UnselectMarker) + } +}