From df8a5ccf8a99eeadee8e4e9898ca9079a95a8036 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 28 Dec 2025 14:40:55 +0900 Subject: [PATCH 01/22] =?UTF-8?q?refactor(PlaceMap):=20=EB=92=A4=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=80=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20Compose=20BackHandler=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 `Fragment`에서 `OnBackPressedCallback`을 통해 처리하던 장소 상세 미리보기(`PlaceDetailPreview`) 화면의 뒤로 가기 로직을 Compose의 `BackHandler`로 마이그레이션했습니다. 이를 통해 View 시스템에 대한 의존성을 줄이고 Compose 내부에서 상태에 따라 뒤로 가기 이벤트를 더 직관적으로 제어하도록 개선했습니다. - **`PlaceDetailPreviewFragment.kt` 리팩토링:** - `OnBackPressedCallback` 객체 생성 및 등록, 생명주기 관련 코드를 모두 제거했습니다. - `LaunchedEffect`를 통해 `backPressedCallback`을 활성화하던 로직을 제거했습니다. - `PlaceDetailPreviewScreen` 컴포저블에 `onBackPress` 콜백을 추가하여, 뒤로 가기 이벤트 발생 시 `viewModel.unselectPlace()`를 호출하도록 연결했습니다. - **`PlaceDetailPreviewScreen.kt` 수정:** - `BackHandler`를 도입하여 `visible` 상태가 `true`일 때만 뒤로 가기 이벤트를 가로채고 `onBackPress`를 실행하도록 구현했습니다. - 더 이상 불필요해진 `onEmpty` 파라미터를 제거하고 `onBackPress` 파라미터를 추가했습니다. --- .../PlaceDetailPreviewFragment.kt | 31 ++----------------- .../component/PlaceDetailPreviewScreen.kt | 8 +++-- 2 files changed, 8 insertions(+), 31 deletions(-) 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 index cf57845..3d10d5c 100644 --- 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 @@ -4,11 +4,9 @@ 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 @@ -47,12 +45,6 @@ class PlaceDetailPreviewFragment( 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, @@ -67,10 +59,6 @@ class PlaceDetailPreviewFragment( val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() val visible = placeDetailUiState is SelectedPlaceUiState.Success - LaunchedEffect(placeDetailUiState) { - backPressedCallback.isEnabled = true - } - Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter, @@ -103,8 +91,8 @@ class PlaceDetailPreviewFragment( onError = { selectedPlace -> showErrorSnackBar(selectedPlace.throwable) }, - onEmpty = { - backPressedCallback.isEnabled = false + onBackPress = { + viewModel.unselectPlace() }, ) } @@ -113,25 +101,10 @@ class PlaceDetailPreviewFragment( } } - 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/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/component/PlaceDetailPreviewScreen.kt index 0e4c101..01b797a 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/placeDetailPreview/component/PlaceDetailPreviewScreen.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,8 +39,11 @@ fun PlaceDetailPreviewScreen( visible: Boolean = false, onClick: (SelectedPlaceUiState) -> Unit = {}, onError: (SelectedPlaceUiState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, + onBackPress: () -> Unit = {}, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = @@ -54,7 +58,7 @@ fun PlaceDetailPreviewScreen( } is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() + is SelectedPlaceUiState.Empty -> Unit } } } From 649b1cdd0880bf38b9829debca8c5ef59e49e29a Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 28 Dec 2025 15:20:13 +0900 Subject: [PATCH 02/22] =?UTF-8?q?refactor(PlaceMap):=20UI=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=ED=99=94=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 모델(`PlaceDetailUiModel`)에 종속적이던 `SelectedPlaceUiState`를 제거하고, 제네릭을 지원하는 `PlaceUiState`로 대체하여 상태 관리 클래스의 범용성을 확보했습니다. 아울러 일부 컴포넌트의 패키지 위치를 정리했습니다. - **`PlaceUiState.kt` 도입:** - 기존 `SelectedPlaceUiState.kt`를 삭제하고, `Loading`, `Empty`, `Success`, `Error` 상태를 가지는 제네릭 실드 인터페이스 `PlaceUiState`를 새로 정의했습니다. - 기존 `Success` 상태에 포함되어 있던 `isSecondary` 로직을 `PlaceUiState.Success`의 확장 프로퍼티로 분리하여 구현했습니다. - **ViewModel 및 UI 로직 수정:** - `PlaceMapViewModel`에서 관리하는 `selectedPlace`의 타입을 `SelectedPlaceUiState`에서 `PlaceUiState`로 변경했습니다. - 이에 따라 `PlaceMapFragment`, `PlaceDetailPreviewFragment`, `PlaceDetailPreviewScreen` 등 관련 UI 컴포넌트에서 상태를 처리하는 분기문과 데이터 참조 코드를 수정했습니다. - **`TimeTagMenu.kt` 패키지 이동:** - `TimeTagMenu` 컴포저블의 위치를 `timeTagSpinner/component` 패키지에서 `placeMap/component`로 이동하여 구조를 단순화했습니다. - **테스트 코드 수정:** - `PlaceMapViewModelTest`의 검증 로직을 `PlaceUiState`를 사용하도록 변경했습니다. --- .../presentation/placeMap/PlaceMapFragment.kt | 9 ++++---- .../placeMap/PlaceMapViewModel.kt | 21 ++++++++++--------- .../placeMap/component/PlaceMapScreen.kt | 2 +- .../component/TimeTagMenu.kt | 2 +- .../placeMap/model/PlaceUiState.kt | 19 +++++++++++++++++ .../placeMap/model/SelectedPlaceUiState.kt | 19 ----------------- .../PlaceDetailPreviewFragment.kt | 6 +++--- .../PlaceDetailPreviewSecondaryFragment.kt | 6 +++--- .../component/PlaceDetailPreviewScreen.kt | 18 ++++++++-------- .../PlaceDetailPreviewSecondaryScreen.kt | 18 ++++++++-------- .../placeList/PlaceMapViewModelTest.kt | 8 +++---- 11 files changed, 65 insertions(+), 63 deletions(-) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{timeTagSpinner => }/component/TimeTagMenu.kt (98%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/SelectedPlaceUiState.kt 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..c759453 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 @@ -26,18 +26,19 @@ 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.component.TimeTagMenu import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked 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.model.PlaceUiState +import com.daedan.festabook.presentation.placeMap.model.isSecondary 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.theme.FestabookTheme import com.naver.maps.map.NaverMap @@ -238,7 +239,7 @@ class PlaceMapFragment( setReorderingAllowed(true) when (selectedPlace) { - is SelectedPlaceUiState.Success -> { + is PlaceUiState.Success -> { mapManager?.selectMarker(selectedPlace.value.place.id) if (selectedPlace.isSecondary) { hide(placeListFragment) @@ -259,7 +260,7 @@ class PlaceMapFragment( ) } - is SelectedPlaceUiState.Empty -> { + is PlaceUiState.Empty -> { mapManager?.unselectMarker() hide(placeDetailPreviewFragment) hide(placeDetailPreviewSecondaryFragment) 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..d3325e7 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 @@ -17,7 +17,7 @@ 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.PlaceListUiState -import com.daedan.festabook.presentation.placeMap.model.SelectedPlaceUiState +import com.daedan.festabook.presentation.placeMap.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap @@ -59,16 +59,17 @@ class PlaceMapViewModel( started = SharingStarted.Lazily, initialValue = TimeTag.EMPTY, ) - private val _selectedPlace: MutableLiveData = MutableLiveData() - val selectedPlace: LiveData = _selectedPlace + private val _selectedPlace: MutableLiveData> = + MutableLiveData() + val selectedPlace: LiveData> = _selectedPlace - val selectedPlaceFlow: StateFlow = + val selectedPlaceFlow: StateFlow> = _selectedPlace .asFlow() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, - initialValue = SelectedPlaceUiState.Loading, + initialValue = PlaceUiState.Loading, ) private val _navigateToDetail = SingleLiveData() @@ -130,23 +131,23 @@ class PlaceMapViewModel( fun selectPlace(placeId: Long) { viewModelScope.launch { - _selectedPlace.value = SelectedPlaceUiState.Loading + _selectedPlace.value = PlaceUiState.Loading placeDetailRepository .getPlaceDetail(placeId = placeId) .onSuccess { - _selectedPlace.value = SelectedPlaceUiState.Success(it.toUiModel()) + _selectedPlace.value = PlaceUiState.Success(it.toUiModel()) }.onFailure { - _selectedPlace.value = SelectedPlaceUiState.Error(it) + _selectedPlace.value = PlaceUiState.Error(it) } } } fun unselectPlace() { - _selectedPlace.value = SelectedPlaceUiState.Empty + _selectedPlace.value = PlaceUiState.Empty } fun onExpandedStateReached() { - val currentPlace = _selectedPlace.value.let { it as? SelectedPlaceUiState.Success }?.value + val currentPlace = _selectedPlace.value.let { it as? PlaceUiState.Success }?.value if (currentPlace != null) { _navigateToDetail.setValue(currentPlace) } 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..555ff7b 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 @@ -14,7 +14,6 @@ import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.NaverMap @@ -35,6 +34,7 @@ fun PlaceMapScreen( timeTags = timeTags, onMapReady = onMapReady, onTimeTagClick = onTimeTagClick, + modifier = modifier, ) } 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 98% 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..e7a1a5d 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 diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt new file mode 100644 index 0000000..9747e07 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.model + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel + +sealed interface PlaceUiState { + data object Loading : PlaceUiState + + data object Empty : PlaceUiState + + data class Success( + val value: T, + ) : PlaceUiState + + data class Error( + val throwable: Throwable, + ) : PlaceUiState +} + +val PlaceUiState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES 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/placeDetailPreview/PlaceDetailPreviewFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt index 3d10d5c..af38b98 100644 --- 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 @@ -27,7 +27,7 @@ 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.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookSpacing @@ -57,7 +57,7 @@ class PlaceDetailPreviewFragment( setContent { FestabookTheme { val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success + val visible = placeDetailUiState is PlaceUiState.Success Box( modifier = Modifier.fillMaxSize(), @@ -73,7 +73,7 @@ class PlaceDetailPreviewFragment( horizontal = festabookSpacing.paddingScreenGutter, ), onClick = { selectedPlace -> - if (selectedPlace !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewScreen + if (selectedPlace !is PlaceUiState.Success) return@PlaceDetailPreviewScreen startPlaceDetailActivity(selectedPlace.value) binding.logger.log( PlacePreviewClick( 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 index 48edaeb..523636a 100644 --- 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 @@ -27,7 +27,7 @@ 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.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookSpacing @@ -62,7 +62,7 @@ class PlaceDetailPreviewSecondaryFragment( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() - val visible = placeDetailUiState is SelectedPlaceUiState.Success + val visible = placeDetailUiState is PlaceUiState.Success LaunchedEffect(placeDetailUiState) { backPressedCallback.isEnabled = true @@ -89,7 +89,7 @@ class PlaceDetailPreviewSecondaryFragment( backPressedCallback.isEnabled = false }, onClick = { - if (it !is SelectedPlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen + if (it !is PlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen appGraph.defaultFirebaseLogger.log( PlacePreviewClick( baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), 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/placeDetailPreview/component/PlaceDetailPreviewScreen.kt index 01b797a..e32cb8f 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/placeDetailPreview/component/PlaceDetailPreviewScreen.kt @@ -25,7 +25,7 @@ import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel 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.PlaceUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography @@ -34,11 +34,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewScreen( - placeUiState: SelectedPlaceUiState, + placeUiState: PlaceUiState, modifier: Modifier = Modifier, visible: Boolean = false, - onClick: (SelectedPlaceUiState) -> Unit = {}, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, + onClick: (PlaceUiState) -> Unit = {}, + onError: (PlaceUiState.Error) -> Unit = {}, onBackPress: () -> Unit = {}, ) { BackHandler(enabled = visible) { @@ -52,13 +52,13 @@ fun PlaceDetailPreviewScreen( .clickable { onClick(placeUiState) }, ) { when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Success -> { + is PlaceUiState.Loading -> Unit + is PlaceUiState.Success -> { PlaceDetailPreviewContent(placeDetail = placeUiState.value) } - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> Unit + is PlaceUiState.Error -> onError(placeUiState) + is PlaceUiState.Empty -> Unit } } } @@ -189,7 +189,7 @@ private fun PlaceDetailPreviewScreenPreview() { Modifier .padding(festabookSpacing.paddingScreenGutter), placeUiState = - SelectedPlaceUiState.Success( + PlaceUiState.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/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt index f6a3ba2..dd4d735 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/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt @@ -19,7 +19,7 @@ import com.daedan.festabook.R import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel 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.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId import com.daedan.festabook.presentation.theme.FestabookTheme @@ -29,11 +29,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewSecondaryScreen( - placeUiState: SelectedPlaceUiState, + placeUiState: PlaceUiState, modifier: Modifier = Modifier, - onError: (SelectedPlaceUiState.Error) -> Unit = {}, + onError: (PlaceUiState.Error) -> Unit = {}, onEmpty: () -> Unit = {}, - onClick: (SelectedPlaceUiState) -> Unit = {}, + onClick: (PlaceUiState) -> Unit = {}, visible: Boolean = false, ) { PreviewAnimatableBox( @@ -47,10 +47,10 @@ fun PlaceDetailPreviewSecondaryScreen( shape = festabookShapes.radius2, ) { when (placeUiState) { - is SelectedPlaceUiState.Loading -> Unit - is SelectedPlaceUiState.Error -> onError(placeUiState) - is SelectedPlaceUiState.Empty -> onEmpty() - is SelectedPlaceUiState.Success -> { + is PlaceUiState.Loading -> Unit + is PlaceUiState.Error -> onError(placeUiState) + is PlaceUiState.Empty -> onEmpty() + is PlaceUiState.Success -> { Row( modifier = Modifier.padding( @@ -94,7 +94,7 @@ private fun PlaceDetailPreviewSecondaryScreenPreview() { visible = true, modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), placeUiState = - SelectedPlaceUiState.Success( + PlaceUiState.Success( FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt index 2f8ba67..2b65e92 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt @@ -14,7 +14,7 @@ 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.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.toUiModel import io.mockk.coEvery import io.mockk.coVerify @@ -190,7 +190,7 @@ class PlaceMapViewModelTest { // then coVerify { placeDetailRepository.getPlaceDetail(1) } - val expected = SelectedPlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) + val expected = PlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -209,7 +209,7 @@ class PlaceMapViewModelTest { advanceUntilIdle() // then - val expected = SelectedPlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) + val expected = PlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -230,7 +230,7 @@ class PlaceMapViewModelTest { advanceUntilIdle() // then - val expected = SelectedPlaceUiState.Empty + val expected = PlaceUiState.Empty val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } From f208c1516229ecb6e0036f7261cd39ddc274cc85 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 28 Dec 2025 16:17:42 +0900 Subject: [PATCH 03/22] =?UTF-8?q?refactor(PlaceMap):=20TimeTag=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20StateFlow=20=EB=B0=8F=20UiStat?= =?UTF-8?q?e=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel`에서 관리하던 시간 태그(`TimeTag`) 관련 데이터를 `LiveData` 및 단순 리스트에서 `StateFlow`로 변경했습니다. 이를 통해 UI 계층에서 데이터의 로딩, 성공, 빈 상태를 일관되게 처리하도록 개선했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `timeTags`와 `selectedTimeTag`를 `StateFlow` 타입으로 변환했습니다. - 기존에 `LiveData`를 Flow로 변환하여 사용하던 임시 프로퍼티 `selectedTimeTagFlow`를 제거했습니다. - 데이터 로드 성공 및 실패 시 `PlaceUiState.Success` 또는 `PlaceUiState.Empty`를 방출하도록 로직을 수정했습니다. - **Fragment 및 UI 로직 수정:** - **`PlaceMapFragment.kt`, `PlaceListFragment.kt`**: `LiveData` 관찰 코드를 `lifecycleScope` 내에서 `StateFlow`를 수집(`collect`)하는 방식으로 변경했습니다. `PlaceUiState` 상태에 따라 지도 마커를 필터링하거나 장소 목록을 갱신하도록 분기 처리를 추가했습니다. - **`PlaceDetailPreviewFragment.kt`**: 상세 미리보기 클릭 시, 선택된 시간 태그가 유효한 상태(`Success`)인지 확인하는 가드 절을 추가하고, 로깅 시 안전하게 데이터를 참조하도록 수정했습니다. - **Compose 컴포넌트 수정:** - **`TimeTagMenu.kt`**: `PlaceUiState`를 인자로 받아 상태가 `Success`일 때만 메뉴를 표시하도록 래퍼 컴포저블을 구현하고, 실제 렌더링 로직은 `TimeTagContent`로 분리했습니다. - **`PlaceMapScreen.kt`**: 변경된 ViewModel 상태 구조에 맞춰 파라미터 타입을 `PlaceUiState`로 수정하고 하위 컴포넌트에 전달하도록 변경했습니다. --- .../java/com/daedan/festabook/FestaBookApp.kt | 2 +- .../presentation/placeMap/PlaceMapFragment.kt | 72 ++++++++++++------- .../placeMap/PlaceMapViewModel.kt | 40 ++++++----- .../placeMap/component/PlaceMapScreen.kt | 52 ++++++++------ .../placeMap/component/TimeTagMenu.kt | 26 ++++++- .../placeCategory/PlaceCategoryFragment.kt | 5 +- .../PlaceDetailPreviewFragment.kt | 11 +-- .../PlaceDetailPreviewSecondaryFragment.kt | 16 +++-- .../placeMap/placeList/PlaceListFragment.kt | 21 +++++- 9 files changed, 160 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt index 8675a95..9047076 100644 --- a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt +++ b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt @@ -36,7 +36,7 @@ class FestaBookApp : Application() { override fun onCreate() { super.onCreate() - setGlobalExceptionHandler() +// setGlobalExceptionHandler() festaBookGraph.inject(this) setupTimber() setupNaverSdk() 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 c759453..895c481 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 @@ -14,12 +14,16 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.di.mapManager.MapManagerGraph +import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener @@ -49,6 +53,7 @@ import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding import dev.zacsweers.metro.createGraphFactory +import kotlinx.coroutines.launch import timber.log.Timber @ContributesIntoMap( @@ -141,28 +146,26 @@ class PlaceMapFragment( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() - val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() + val selectedTimeTag by viewModel.selectedTimeTag.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), - ) - } + TimeTagMenu( + timeTagsState = timeTags, + selectedTimeTagState = selectedTimeTag, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = 24.dp), + onTimeTagClick = { timeTag -> + viewModel.onDaySelected(timeTag) + binding.logger.log( + PlaceTimeTagSelected( + baseLogData = binding.logger.getBaseLogData(), + timeTagName = timeTag.name, + ), + ) + }, + ) } } } @@ -188,8 +191,22 @@ class PlaceMapFragment( is PlaceListUiState.Loading -> Unit is PlaceListUiState.Success -> { mapManager?.setupMarker(placeGeographies.value) - viewModel.selectedTimeTag.observe(viewLifecycleOwner) { selectedTimeTag -> - mapManager?.filterMarkersByTimeTag(selectedTimeTag.timeTagId) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.selectedTimeTag.collect { selectedTimeTag -> + when (selectedTimeTag) { + is PlaceUiState.Success -> { + mapManager?.filterMarkersByTimeTag(selectedTimeTag.value.timeTagId) + } + + is PlaceUiState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + } } } @@ -250,11 +267,18 @@ class PlaceMapFragment( hide(placeDetailPreviewSecondaryFragment) show(placeDetailPreviewFragment) } + val currentTimeTag = viewModel.selectedTimeTag.value + val timeTagName = + if (currentTimeTag is PlaceUiState.Success) { + currentTimeTag.value.name + } else { + "undefined" + } binding.logger.log( PlaceMarkerClick( baseLogData = binding.logger.getBaseLogData(), placeId = selectedPlace.value.place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefined", + timeTagName = timeTagName, category = selectedPlace.value.place.category.name, ), ) 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 d3325e7..f995597 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 @@ -46,19 +46,12 @@ class PlaceMapViewModel( 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 _timeTags = MutableStateFlow>>(PlaceUiState.Empty) + val timeTags: StateFlow>> = _timeTags.asStateFlow() + + private val _selectedTimeTag = MutableStateFlow>(PlaceUiState.Empty) + val selectedTimeTag: StateFlow> = _selectedTimeTag.asStateFlow() + private val _selectedPlace: MutableLiveData> = MutableLiveData() val selectedPlace: LiveData> = _selectedPlace @@ -110,23 +103,32 @@ class PlaceMapViewModel( placeListRepository .getTimeTags() .onSuccess { timeTags -> - _timeTags.value = timeTags + _timeTags.tryEmit( + PlaceUiState.Success(timeTags), + ) }.onFailure { - _timeTags.value = emptyList() + _timeTags.tryEmit(PlaceUiState.Empty) } // 기본 선택값 - if (!timeTags.value.isEmpty()) { - _selectedTimeTag.value = _timeTags.value.first() + val timeTags = timeTags.value + if (timeTags is PlaceUiState.Success && timeTags.value.isNotEmpty()) { + _selectedTimeTag.tryEmit( + PlaceUiState.Success( + timeTags.value.first(), + ), + ) } else { - _selectedTimeTag.value = TimeTag.EMPTY + _selectedTimeTag.tryEmit(PlaceUiState.Empty) } } } fun onDaySelected(item: TimeTag) { unselectPlace() - _selectedTimeTag.value = item + _selectedTimeTag.tryEmit( + PlaceUiState.Success(item), + ) } fun selectPlace(placeId: Long) { 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 555ff7b..3e581e2 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 @@ -13,6 +13,7 @@ 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.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme @@ -21,8 +22,8 @@ import com.naver.maps.map.NaverMap @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceMapScreen( - timeTagTitle: String, - timeTags: List, + timeTagsState: PlaceUiState>, + selectedTimeTagState: PlaceUiState, places: List, modifier: Modifier = Modifier, onMapReady: (NaverMap) -> Unit = {}, @@ -30,8 +31,8 @@ fun PlaceMapScreen( onTimeTagClick: (TimeTag) -> Unit = {}, ) { PlaceMapContent( - title = timeTagTitle, - timeTags = timeTags, + timeTagsState = timeTagsState, + selectedTimeTagState = selectedTimeTagState, onMapReady = onMapReady, onTimeTagClick = onTimeTagClick, modifier = modifier, @@ -40,8 +41,8 @@ fun PlaceMapScreen( @Composable private fun PlaceMapContent( - timeTags: List, - title: String, + timeTagsState: PlaceUiState>, + selectedTimeTagState: PlaceUiState, onMapReady: (NaverMap) -> Unit, onTimeTagClick: (TimeTag) -> Unit, modifier: Modifier = Modifier, @@ -53,20 +54,18 @@ private fun PlaceMapContent( Column( modifier = Modifier.wrapContentSize(), ) { - if (timeTags.isNotEmpty()) { - TimeTagMenu( - title = title, - timeTags = timeTags, - onTimeTagClick = { timeTag -> - onTimeTagClick(timeTag) - }, - modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), - ) - } + TimeTagMenu( + timeTagsState = timeTagsState, + selectedTimeTagState = selectedTimeTagState, + onTimeTagClick = { timeTag -> + onTimeTagClick(timeTag) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = 24.dp), + ) PlaceCategoryScreen() } } @@ -76,13 +75,20 @@ private fun PlaceMapContent( @Composable private fun PlaceMapScreenPreview() { FestabookTheme { - PlaceMapScreen( - timeTagTitle = "테스트", - timeTags = + val timeTagsState = + PlaceUiState.Success( listOf( TimeTag(1, "테스트1"), TimeTag(2, "테스트2"), ), + ) + val selectedTimeTagState = + PlaceUiState.Success( + TimeTag(1, "테스트1"), + ) + PlaceMapScreen( + timeTagsState = timeTagsState, + selectedTimeTagState = selectedTimeTagState, places = (0..100).map { PlaceUiModel( diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index e7a1a5d..12d82ab 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -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.model.PlaceUiState 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: PlaceUiState>, + selectedTimeTagState: PlaceUiState, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + when (timeTagsState) { + is PlaceUiState.Success -> { + if (selectedTimeTagState !is PlaceUiState.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, @@ -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/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt index edf6650..ff0f011 100644 --- 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 @@ -13,7 +13,6 @@ 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 @@ -52,9 +51,7 @@ class PlaceCategoryFragment( val initialCategories = PlaceCategoryUiModel.entries // StateFlow로 변경 시 asFlow 제거 예정 val timeTagChanged = - viewModel.selectedTimeTag - .asFlow() - .collectAsStateWithLifecycle(viewLifecycleOwner) + viewModel.selectedTimeTag.collectAsStateWithLifecycle(viewLifecycleOwner) var selectedCategoriesState by remember(timeTagChanged.value) { mutableStateOf( emptySet(), 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 index af38b98..f657d95 100644 --- 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 @@ -73,7 +73,12 @@ class PlaceDetailPreviewFragment( horizontal = festabookSpacing.paddingScreenGutter, ), onClick = { selectedPlace -> - if (selectedPlace !is PlaceUiState.Success) return@PlaceDetailPreviewScreen + val selectedTimeTag = viewModel.selectedTimeTag.value + if (selectedPlace !is PlaceUiState.Success || + selectedTimeTag !is PlaceUiState.Success + ) { + return@PlaceDetailPreviewScreen + } startPlaceDetailActivity(selectedPlace.value) binding.logger.log( PlacePreviewClick( @@ -81,9 +86,7 @@ class PlaceDetailPreviewFragment( placeName = selectedPlace.value.place.title ?: "undefined", - timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", + timeTag = selectedTimeTag.value.name, category = selectedPlace.value.place.category.name, ), ) 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 index 523636a..0729421 100644 --- 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 @@ -88,16 +88,20 @@ class PlaceDetailPreviewSecondaryFragment( onEmpty = { backPressedCallback.isEnabled = false }, - onClick = { - if (it !is PlaceUiState.Success) return@PlaceDetailPreviewSecondaryScreen + onClick = { selectedPlace -> + val selectedTimeTag = viewModel.selectedTimeTag.value + if (selectedPlace !is PlaceUiState.Success || + selectedTimeTag !is PlaceUiState.Success + ) { + return@PlaceDetailPreviewSecondaryScreen + } appGraph.defaultFirebaseLogger.log( PlacePreviewClick( baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeName = it.value.place.title ?: "undefined", + placeName = selectedPlace.value.place.title ?: "undefined", timeTag = - viewModel.selectedTimeTag.value?.name - ?: "undefined", - category = it.value.place.category.name, + selectedTimeTag.value.name, + category = selectedPlace.value.place.category.name, ), ) }, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 779b277..09f9e8f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -24,6 +24,7 @@ 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.domain.model.TimeTag import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener import com.daedan.festabook.presentation.common.showErrorSnackBar @@ -34,6 +35,7 @@ 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.model.PlaceUiState 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 @@ -105,8 +107,18 @@ class PlaceListFragment( ) }, onPlaceLoad = { - viewModel.selectedTimeTagFlow.collect { - childViewModel.updatePlacesByTimeTag(it.timeTagId) + viewModel.selectedTimeTag.collect { selectedTimeTag -> + when (selectedTimeTag) { + is PlaceUiState.Success -> { + childViewModel.updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) + } + + is PlaceUiState.Empty -> { + childViewModel.updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } } }, onError = { @@ -136,11 +148,14 @@ class PlaceListFragment( override fun onPlaceClicked(place: PlaceUiModel) { Timber.d("onPlaceClicked: $place") startPlaceDetailActivity(place) + val selectedTimeTag = viewModel.selectedTimeTag.value + val timeTagName = + if (selectedTimeTag is PlaceUiState.Success) selectedTimeTag.value.name else "undefined" appGraph.defaultFirebaseLogger.log( PlaceItemClick( baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), placeId = place.id, - timeTagName = viewModel.selectedTimeTag.value?.name ?: "undefinded", + timeTagName = timeTagName, category = place.category.name, ), ) From a57f04e828de270d79501117de436f6546fae5e0 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Mon, 29 Dec 2025 11:42:27 +0900 Subject: [PATCH 04/22] =?UTF-8?q?refactor(PlaceMap):=20ViewModel=20LiveDat?= =?UTF-8?q?a=20->=20Flow=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel`과 `PlaceListViewModel`에서 사용하던 `LiveData`와 `SingleLiveData`를 모두 제거하고, `StateFlow`와 `SharedFlow`로 전면 교체했습니다. 이를 통해 불필요한 Flow 변환 코드를 제거하고 상태 관리 방식을 통일했습니다. - **`PlaceMapViewModel.kt` 수정:** - UI 상태(`initialMapSetting`, `placeGeographies`, `selectedPlace`, `isExceededMaxLength`, `selectedCategories`)를 `LiveData`에서 `StateFlow`로 변경했습니다. - 일회성 이벤트(`navigateToDetail`, `backToInitialPositionClicked`, `onMapViewClick`)를 `SingleLiveData`에서 `SharedFlow`로 변경하고 `tryEmit`을 사용하여 이벤트를 발행하도록 수정했습니다. - 기존에 `LiveData`를 `Flow`로 변환하여 제공하던 중복 프로퍼티들(`selectedPlaceFlow`, `isExceededMaxLengthFlow`, `onMapViewClickFlow`)을 제거했습니다. - 메뉴 아이템 재클릭 이벤트를 처리하기 위한 `onMenuItemReClick` 함수와 `SharedFlow`를 추가했습니다. - **`PlaceListViewModel.kt` 수정:** - 장소 목록 상태(`places`)를 `LiveData`에서 `StateFlow`로 변경했습니다. - 기존에 `places`를 변환하여 노출하던 `placesFlow`를 제거하고, `places` 프로퍼티가 직접 `StateFlow`를 반환하도록 수정했습니다. --- .../placeMap/PlaceMapViewModel.kt | 115 +++++++++--------- .../placeMap/placeList/PlaceListViewModel.kt | 20 +-- 2 files changed, 64 insertions(+), 71 deletions(-) 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 f995597..fee618f 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,28 @@ 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.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.model.PlaceCoordinateUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -37,14 +32,15 @@ class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, private val placeDetailRepository: PlaceDetailRepository, ) : ViewModel() { - private val _initialMapSetting: MutableLiveData> = - MutableLiveData() - val initialMapSetting: LiveData> = _initialMapSetting + private val _initialMapSetting: MutableStateFlow> = + MutableStateFlow(PlaceUiState.Loading) + val initialMapSetting: StateFlow> = + _initialMapSetting.asStateFlow() - private val _placeGeographies: MutableLiveData>> = - MutableLiveData() - val placeGeographies: LiveData>> - get() = _placeGeographies + private val _placeGeographies: MutableStateFlow>> = + MutableStateFlow(PlaceUiState.Loading) + val placeGeographies: StateFlow>> = + _placeGeographies.asStateFlow() private val _timeTags = MutableStateFlow>>(PlaceUiState.Empty) val timeTags: StateFlow>> = _timeTags.asStateFlow() @@ -52,46 +48,45 @@ class PlaceMapViewModel( private val _selectedTimeTag = MutableStateFlow>(PlaceUiState.Empty) val selectedTimeTag: StateFlow> = _selectedTimeTag.asStateFlow() - private val _selectedPlace: MutableLiveData> = - MutableLiveData() - val selectedPlace: LiveData> = _selectedPlace + private val _selectedPlace: MutableStateFlow> = + MutableStateFlow(PlaceUiState.Loading) + val selectedPlace: StateFlow> = _selectedPlace.asStateFlow() - val selectedPlaceFlow: StateFlow> = - _selectedPlace - .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = PlaceUiState.Loading, - ) - - private val _navigateToDetail = SingleLiveData() - val navigateToDetail: LiveData = _navigateToDetail + private val _navigateToDetail = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val navigateToDetail: SharedFlow = _navigateToDetail.asSharedFlow() - private val _isExceededMaxLength: MutableLiveData = MutableLiveData() - val isExceededMaxLength: LiveData = _isExceededMaxLength + private val _isExceededMaxLength: MutableStateFlow = MutableStateFlow(false) + val isExceededMaxLength: StateFlow = _isExceededMaxLength.asStateFlow() - val isExceededMaxLengthFlow: StateFlow = - _isExceededMaxLength - .asFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = false, - ) + private val _backToInitialPositionClicked: MutableSharedFlow> = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val backToInitialPositionClicked: SharedFlow> = + _backToInitialPositionClicked.asSharedFlow() - private val _backToInitialPositionClicked: MutableLiveData> = MutableLiveData() - val backToInitialPositionClicked: LiveData> = _backToInitialPositionClicked + private val _selectedCategories: MutableStateFlow> = + MutableStateFlow( + PlaceCategoryUiModel.entries, + ) + val selectedCategories: StateFlow> = + _selectedCategories.asStateFlow() - private val _selectedCategories: MutableLiveData> = MutableLiveData() - val selectedCategories: LiveData> = _selectedCategories + private val _onMapViewClick: MutableSharedFlow> = + MutableSharedFlow( + extraBufferCapacity = 1, + ) + val onMapViewClick: SharedFlow> = _onMapViewClick.asSharedFlow() - private val _onMapViewClick: MutableLiveData> = MutableLiveData() - val onMapViewClick: LiveData> = _onMapViewClick + private val _onMenuItemReClick: MutableSharedFlow> = + MutableSharedFlow( + extraBufferCapacity = 1, + ) - val onMapViewClickFlow: Flow> = - _onMapViewClick - .asFlow() + val onMenuItemReClick: SharedFlow> = _onMenuItemReClick.asSharedFlow() init { loadOrganizationGeography() @@ -151,12 +146,12 @@ class PlaceMapViewModel( fun onExpandedStateReached() { val currentPlace = _selectedPlace.value.let { it as? PlaceUiState.Success }?.value if (currentPlace != null) { - _navigateToDetail.setValue(currentPlace) + _navigateToDetail.tryEmit(currentPlace) } } fun onBackToInitialPositionClicked() { - _backToInitialPositionClicked.value = Event(Unit) + _backToInitialPositionClicked.tryEmit(Event(Unit)) } fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { @@ -168,24 +163,32 @@ class PlaceMapViewModel( } fun onMapViewClick() { - _onMapViewClick.value = Event(Unit) + _onMapViewClick.tryEmit(Event(Unit)) + } + + fun onMenuItemReClick() { + _onMenuItemReClick.tryEmit(Event(Unit)) } private fun loadOrganizationGeography() { viewModelScope.launch { placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _initialMapSetting.value = - PlaceListUiState.Success(organizationGeography.toUiModel()) + _initialMapSetting.tryEmit( + PlaceUiState.Success(organizationGeography.toUiModel()), + ) } launch { placeListRepository .getPlaceGeographies() .onSuccess { placeGeographies -> - _placeGeographies.value = - PlaceListUiState.Success(placeGeographies.map { it.toUiModel() }) + _placeGeographies.tryEmit( + PlaceUiState.Success(placeGeographies.map { it.toUiModel() }), + ) }.onFailure { - _placeGeographies.value = PlaceListUiState.Error(it) + _placeGeographies.tryEmit( + PlaceUiState.Error(it), + ) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt index 184101a..480efcf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt @@ -1,9 +1,6 @@ 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 @@ -16,9 +13,9 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -30,16 +27,9 @@ class PlaceListViewModel( 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(), - ) + private val _places: MutableStateFlow>> = + MutableStateFlow(PlaceListUiState.Loading()) + val places: StateFlow>> = _places.asStateFlow() init { loadAllPlaces() From c8055f08a82bf50bd3208fe934dfc7a76023004e Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Mon, 29 Dec 2025 11:43:14 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor(PlaceMap):=20Fragment=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20Compose=20UI=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapFragment` 내부에서 `ChildFragmentManager`를 통해 관리하던 하위 Fragment들(`PlaceListFragment`, `PlaceCategoryFragment`, `PlaceDetailPreviewFragment` 등)을 제거하고, 순수 Jetpack Compose 기반의 `PlaceMapScreen`으로 통합하여 구조를 단순화했습니다. - **`PlaceMapFragment.kt` 리팩토링:** - 복잡한 Fragment 트랜잭션 로직을 제거하고, `ComposeView`에서 `PlaceMapScreen`을 통해 전체 UI를 구성하도록 변경했습니다. - `PlaceMapViewModel`과 `PlaceListViewModel`의 데이터를 `collectAsStateWithLifecycle`로 구독하고, `LaunchedEffect`를 사용하여 `MapManager` 초기화, 마커 필터링, 로깅, 네비게이션 등의 사이드 이펙트를 처리하도록 재구현했습니다. - 이미지 프리로딩(`preloadImages`) 로직을 Fragment 내부로 이동시켰습니다. - **`PlaceMapScreen.kt` 및 컴포넌트 재구성:** - `PlaceCategoryScreen`, `PlaceListScreen`, `PlaceDetailPreviewScreen` 등 개별 컴포저블을 조합하여 하나의 화면을 구성하도록 구조를 변경했습니다. - `NaverMapContent`가 `NaverMap` 인스턴스를 상위로 전달하도록 수정하여, `PlaceMapFragment` 레벨에서 지도 객체를 제어할 수 있게 개선했습니다. - 장소 상세 미리보기(`Preview`) 노출 여부에 따라 `PlaceListScreen`의 투명도를 조절하는 UI 로직을 추가했습니다. - **`PlaceListScreen.kt` 수정:** - BottomSheet 상태(`bottomSheetState`)에 따라 현재 위치 버튼 등 지도 컨트롤 UI의 노출 여부를 제어하는 로직을 추가했습니다. - **기타 변경사항:** - `PlaceDetailPreviewSecondaryScreen`에 `BackHandler`를 추가하여 뒤로가기 동작을 Compose 내에서 처리하도록 했습니다. - `PlaceMapViewModel`의 프로퍼티 명 변경(`selectedPlaceFlow` -> `selectedPlace`)을 반영했습니다. --- .../presentation/placeMap/PlaceMapFragment.kt | 578 +++++++++++------- .../placeMap/component/NaverMapContent.kt | 28 +- .../placeMap/component/PlaceMapScreen.kt | 153 +++-- .../PlaceDetailPreviewFragment.kt | 2 +- .../PlaceDetailPreviewSecondaryFragment.kt | 2 +- .../PlaceDetailPreviewSecondaryScreen.kt | 5 + .../placeMap/placeList/PlaceListFragment.kt | 36 +- .../placeList/component/PlaceListScreen.kt | 38 +- 8 files changed, 515 insertions(+), 327 deletions(-) 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 895c481..08b968d 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,25 +2,31 @@ 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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView 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.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle +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.domain.model.TimeTag @@ -29,31 +35,42 @@ 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.common.toPx -import com.daedan.festabook.presentation.placeMap.component.NaverMapContent -import com.daedan.festabook.presentation.placeMap.component.TimeTagMenu +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.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter +import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick +import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected import com.daedan.festabook.presentation.placeMap.mapManager.MapManager +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.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.isSecondary -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.theme.FestabookColor +import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetValue +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 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.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import timber.log.Timber @ContributesIntoMap( @@ -63,101 +80,226 @@ 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 placeMapViewModel: PlaceMapViewModel by viewModels() - private val viewModel: PlaceMapViewModel by viewModels() + private val placeListViewModel: PlaceListViewModel 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 places by placeListViewModel.places.collectAsStateWithLifecycle() + val selectedPlace by placeMapViewModel.selectedPlace.collectAsStateWithLifecycle() + val timeTags by placeMapViewModel.timeTags.collectAsStateWithLifecycle() + val selectedTimeTag by placeMapViewModel.selectedTimeTag.collectAsStateWithLifecycle() + val initialCategories = PlaceCategoryUiModel.entries + val isExceedMaxLength by placeMapViewModel.isExceededMaxLength.collectAsStateWithLifecycle() + val timeTagChanged = + placeMapViewModel.selectedTimeTag.collectAsStateWithLifecycle(viewLifecycleOwner) + var selectedCategoriesState by remember(timeTagChanged.value) { + mutableStateOf( + emptySet(), + ) + } + + val isPlacePreviewVisible by remember { + derivedStateOf { + selectedPlace is PlaceUiState.Success && + !(selectedPlace as PlaceUiState.Success).isSecondary } } - } - } - binding.cvTimeTagSpinner.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() - val selectedTimeTag by viewModel.selectedTimeTag.collectAsStateWithLifecycle() + + val isPlaceSecondaryPreviewVisible by remember { + derivedStateOf { + selectedPlace is PlaceUiState.Success && + (selectedPlace as PlaceUiState.Success).isSecondary + } + } + + val scope = rememberCoroutineScope() + val bottomSheetState = rememberPlaceListBottomSheetState() + + var mapManager by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + placeMapViewModel.placeGeographies.collect { placeGeographies -> + when (placeGeographies) { + is PlaceUiState.Loading -> Unit + is PlaceUiState.Success -> { + mapManager?.setupMarker(placeGeographies.value) + placeMapViewModel.selectedTimeTag.collect { selectedTimeTag -> + when (selectedTimeTag) { + is PlaceUiState.Success -> { + mapManager?.filterMarkersByTimeTag( + selectedTimeTag.value.timeTagId, + ) + } + + is PlaceUiState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } + } + } + + is PlaceUiState.Error -> { + Timber.w( + placeGeographies.throwable, + "PlaceListFragment: ${placeGeographies.throwable.message}", + ) + showErrorSnackBar(placeGeographies.throwable) + } + + else -> Unit + } + } + } + + LaunchedEffect(Unit) { + placeMapViewModel.backToInitialPositionClicked.collect { + mapManager?.moveToPosition() + } + } + + LaunchedEffect(Unit) { + placeMapViewModel.selectedCategories.collect { selectedCategories -> + if (selectedCategories.isEmpty()) { + mapManager?.clearFilter() + } else { + mapManager?.filterMarkersByCategories(selectedCategories) + } + } + } + + LaunchedEffect(Unit) { + placeMapViewModel.navigateToDetail.collect { selectedPlace -> + startPlaceDetailActivity(selectedPlace) + } + } + + LaunchedEffect(Unit) { + placeListViewModel.places.first { + it is PlaceListUiState.Success || it is PlaceListUiState.Error + } + placeMapViewModel.selectedCategories.collect { selectedCategories -> + if (selectedCategories.isEmpty()) { + placeListViewModel.clearPlacesFilter() + } else { + placeListViewModel.updatePlacesByCategories(selectedCategories) + } + } + } + + LaunchedEffect(Unit) { + placeMapViewModel.onMenuItemReClick.collect { + mapManager?.moveToPosition() + if (!isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible) return@collect + placeMapViewModel.onMapViewClick() + appGraph.defaultFirebaseLogger.log( + PlaceMapButtonReClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + ), + ) + } + } + + LaunchedEffect(Unit) { + placeMapViewModel.onMapViewClick + .filter { !isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible } + .collect { + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } + } + + LaunchedEffect(selectedPlace) { + // 스마트 캐스팅을 위해 로컬 변수에 할당 + when (val place = selectedPlace) { + is PlaceUiState.Success -> { + mapManager?.selectMarker(place.value.place.id) + + val currentTimeTag = placeMapViewModel.selectedTimeTag.value + val timeTagName = + if (currentTimeTag is PlaceUiState.Success) { + currentTimeTag.value.name + } else { + "undefined" + } + binding.logger.log( + PlaceMarkerClick( + baseLogData = binding.logger.getBaseLogData(), + placeId = place.value.place.id, + timeTagName = timeTagName, + category = place.value.place.category.name, + ), + ) + } + + is PlaceUiState.Empty -> { + mapManager?.unselectMarker() + } + + else -> Unit + } + } + FestabookTheme { - TimeTagMenu( + PlaceMapScreen( + places = places, + selectedPlaceUiState = selectedPlace, timeTagsState = timeTags, selectedTimeTagState = selectedTimeTag, - modifier = - Modifier - .background( - FestabookColor.white, - ).padding(horizontal = 24.dp), + onMapReady = { map -> + scope.launch { + map.addOnLocationChangeListener { + binding.logger.log( + CurrentLocationChecked( + baseLogData = binding.logger.getBaseLogData(), + ), + ) + } + map.locationSource = locationSource + placeMapViewModel.initialMapSetting.collect { initialMapSetting -> + if (initialMapSetting !is PlaceUiState.Success) return@collect + if (mapManager == null) { + val graph = + createGraphFactory().create( + map, + initialMapSetting.value, + placeMapViewModel, + getInitialPadding(requireContext()), + ) + mapManager = graph.mapManager + mapManager?.setupBackToInitialPosition { isExceededMaxLength -> + placeMapViewModel.setIsExceededMaxLength( + isExceededMaxLength, + ) + } + } + } + } + }, onTimeTagClick = { timeTag -> - viewModel.onDaySelected(timeTag) + placeMapViewModel.onDaySelected(timeTag) binding.logger.log( PlaceTimeTagSelected( baseLogData = binding.logger.getBaseLogData(), @@ -165,149 +307,169 @@ class PlaceMapFragment( ), ) }, - ) - } - } - } - } - - 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) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.selectedTimeTag.collect { selectedTimeTag -> + onPlaceClick = { place -> + Timber.d("onPlaceClicked: $place") + startPlaceDetailActivity(place) + val selectedTimeTag = placeMapViewModel.selectedTimeTag.value + val timeTagName = + if (selectedTimeTag is PlaceUiState.Success) selectedTimeTag.value.name else "undefined" + appGraph.defaultFirebaseLogger.log( + PlaceItemClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + placeId = place.id, + timeTagName = timeTagName, + category = place.category.name, + ), + ) + }, + onPlaceLoad = { + placeMapViewModel.selectedTimeTag.collect { selectedTimeTag -> when (selectedTimeTag) { is PlaceUiState.Success -> { - mapManager?.filterMarkersByTimeTag(selectedTimeTag.value.timeTagId) + placeListViewModel.updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) } is PlaceUiState.Empty -> { - mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + placeListViewModel.updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) } else -> Unit } } - } - } - } - - is PlaceListUiState.Error -> { - Timber.w( - placeGeographies.throwable, - "PlaceListFragment: ${placeGeographies.throwable.message}", + }, + isExceedMaxLength = isExceedMaxLength, + onPlaceLoadFinish = { places -> + preloadImages( + requireContext(), + places, + ) + }, + onPlaceListError = { + showErrorSnackBar(it.throwable) + }, + onBackToInitialPositionClick = { + placeMapViewModel.onBackToInitialPositionClicked() + appGraph.defaultFirebaseLogger.log( + PlaceBackToSchoolClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + ), + ) + }, + onCategoryClick = { selectedCategories -> + selectedCategoriesState = selectedCategories + placeMapViewModel.unselectPlace() + placeMapViewModel.setSelectedCategories(selectedCategories.toList()) + appGraph.defaultFirebaseLogger.log( + PlaceCategoryClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + currentCategories = selectedCategories.joinToString(",") { it.toString() }, + ), + ) + }, + onDisplayAllClick = { selectedCategories -> + selectedCategoriesState = selectedCategories + placeMapViewModel.unselectPlace() + placeMapViewModel.setSelectedCategories(initialCategories) + }, + isPlacePreviewVisible = isPlacePreviewVisible, + isPlaceSecondaryPreviewVisible = isPlaceSecondaryPreviewVisible, + onMapDrag = { + placeMapViewModel.onMapViewClick() + }, + selectedCategoriesState = selectedCategoriesState, + initialCategories = initialCategories, + bottomSheetState = bottomSheetState, + onBackPress = { + placeMapViewModel.unselectPlace() + }, + onPlacePreviewClick = { selectedPlace -> + val selectedTimeTag = placeMapViewModel.selectedTimeTag.value + if (selectedPlace is PlaceUiState.Success && + selectedTimeTag is PlaceUiState.Success + ) { + startPlaceDetailActivity(selectedPlace.value) + binding.logger.log( + PlacePreviewClick( + baseLogData = binding.logger.getBaseLogData(), + placeName = + selectedPlace.value.place.title + ?: "undefined", + timeTag = selectedTimeTag.value.name, + category = selectedPlace.value.place.category.name, + ), + ) + } + }, + onPlacePreviewError = { + showErrorSnackBar(it.throwable) + }, ) - showErrorSnackBar(placeGeographies.throwable) } - - 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()), - ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - viewModel.setIsExceededMaxLength(isExceededMaxLength) - } - } - } + private fun startPlaceDetailActivity(place: PlaceUiModel) { + placeMapViewModel.selectPlace(place.id) + } - viewModel.backToInitialPositionClicked.observe(viewLifecycleOwner) { - mapManager?.moveToPosition() - } + override fun onMenuItemReClick() { + placeMapViewModel.unselectPlace() + placeMapViewModel.onMenuItemReClick() + } - viewModel.selectedCategories.observe(viewLifecycleOwner) { selectedCategories -> - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) - } - } + private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { + Timber.d("start detail activity") + val intent = PlaceDetailActivity.newIntent(requireContext(), placeDetail) + startActivity(intent) + } - viewModel.selectedPlace.observe(viewLifecycleOwner) { selectedPlace -> - childFragmentManager.commit { - setReorderingAllowed(true) - - when (selectedPlace) { - is PlaceUiState.Success -> { - mapManager?.selectMarker(selectedPlace.value.place.id) - if (selectedPlace.isSecondary) { - hide(placeListFragment) - hide(placeDetailPreviewFragment) - show(placeDetailPreviewSecondaryFragment) - } else { - hide(placeListFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeDetailPreviewFragment) - } - val currentTimeTag = viewModel.selectedTimeTag.value - val timeTagName = - if (currentTimeTag is PlaceUiState.Success) { - currentTimeTag.value.name - } else { - "undefined" - } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = selectedPlace.value.place.id, - timeTagName = timeTagName, - category = selectedPlace.value.place.category.name, - ), - ) - } + // 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() - is PlaceUiState.Empty -> { - mapManager?.unselectMarker() - hide(placeDetailPreviewFragment) - hide(placeDetailPreviewSecondaryFragment) - show(placeListFragment) - } + 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() - else -> Unit + runCatching { + withTimeout(2000) { + imageLoader.execute(request) + } + }.onFailure { + imageLoader.shutdown() + }.getOrNull() + } + 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 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..73da5e5 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,10 +5,13 @@ 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -19,26 +22,27 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.naver.maps.map.MapView import com.naver.maps.map.NaverMap +import kotlinx.coroutines.suspendCancellableCoroutine @Composable fun NaverMapContent( modifier: Modifier = Modifier, onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, - content: @Composable () -> Unit, + content: @Composable (NaverMap?) -> Unit, ) { val context = LocalContext.current val mapView = remember { MapView(context) } + var naverMap by remember { mutableStateOf(null) } + LaunchedEffect(mapView) { + naverMap = mapView.getMapAndRunCallback(onMapReady) + } AndroidView( - factory = { - mapView.apply { - getMapAsync(onMapReady) - } - }, + factory = { mapView }, modifier = modifier.dragInterceptor(onMapDrag), ) RegisterMapLifeCycle(mapView) - content() + content(naverMap) } 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/component/PlaceMapScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceMapScreen.kt index 3e581e2..bfea58c 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,56 +1,63 @@ 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.draw.alpha import androidx.compose.ui.unit.dp import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel 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.PlaceUiState import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen +import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen +import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListScreen import com.daedan.festabook.presentation.theme.FestabookColor -import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookSpacing import com.naver.maps.map.NaverMap -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaceMapScreen( - timeTagsState: PlaceUiState>, - selectedTimeTagState: PlaceUiState, - places: List, - modifier: Modifier = Modifier, - onMapReady: (NaverMap) -> Unit = {}, - onPlaceClick: (PlaceUiModel) -> Unit = {}, - onTimeTagClick: (TimeTag) -> Unit = {}, -) { - PlaceMapContent( - timeTagsState = timeTagsState, - selectedTimeTagState = selectedTimeTagState, - onMapReady = onMapReady, - onTimeTagClick = onTimeTagClick, - modifier = modifier, - ) -} - -@Composable -private fun PlaceMapContent( + places: PlaceListUiState>, + initialCategories: List, + selectedCategoriesState: Set, + selectedPlaceUiState: PlaceUiState, timeTagsState: PlaceUiState>, selectedTimeTagState: PlaceUiState, onMapReady: (NaverMap) -> Unit, onTimeTagClick: (TimeTag) -> Unit, + onMapDrag: () -> Unit, + onPlaceClick: (PlaceUiModel) -> Unit, + onPlacePreviewClick: (PlaceUiState) -> Unit, + onBackPress: () -> Unit, + onPlacePreviewError: (PlaceUiState.Error) -> Unit, + isExceedMaxLength: Boolean, + onPlaceLoadFinish: (List) -> Unit, + onPlaceLoad: suspend () -> Unit, + onPlaceListError: (PlaceListUiState.Error>) -> Unit, + onBackToInitialPositionClick: () -> Unit, + onCategoryClick: (Set) -> Unit, + onDisplayAllClick: (Set) -> Unit, + isPlacePreviewVisible: Boolean, + isPlaceSecondaryPreviewVisible: Boolean, + bottomSheetState: PlaceListBottomSheetState, modifier: Modifier = Modifier, ) { NaverMapContent( modifier = modifier.fillMaxSize(), onMapReady = onMapReady, - ) { + onMapDrag = onMapDrag, + ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), ) { @@ -66,42 +73,68 @@ private fun PlaceMapContent( FestabookColor.white, ).padding(horizontal = 24.dp), ) - PlaceCategoryScreen() - } - } -} - -@Preview(showBackground = true) -@Composable -private fun PlaceMapScreenPreview() { - FestabookTheme { - val timeTagsState = - PlaceUiState.Success( - listOf( - TimeTag(1, "테스트1"), - TimeTag(2, "테스트2"), - ), - ) - val selectedTimeTagState = - PlaceUiState.Success( - TimeTag(1, "테스트1"), + PlaceCategoryScreen( + initialCategories = initialCategories, + selectedCategories = selectedCategoriesState, + onCategoryClick = onCategoryClick, + onDisplayAllClick = onDisplayAllClick, ) - PlaceMapScreen( - timeTagsState = timeTagsState, - selectedTimeTagState = selectedTimeTagState, - places = - (0..100).map { - PlaceUiModel( - id = it.toLong(), - imageUrl = null, - title = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - description = "테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트", - location = "테스트테스트테스트테스트테스트테스트테스트테스트테스트", - category = PlaceCategoryUiModel.BAR, - isBookmarked = true, - timeTagId = listOf(1), - ) - }, - ) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + NaverMapLogo( + modifier = + Modifier.padding( + horizontal = festabookSpacing.paddingScreenGutter, + ), + ) + + PlaceListScreen( + modifier = + Modifier.alpha( + if (!isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible) 1f else 0f, + ), + placesUiState = places, + map = naverMap, + onPlaceClick = onPlaceClick, + bottomSheetState = bottomSheetState, + isExceedMaxLength = isExceedMaxLength, + onPlaceLoadFinish = onPlaceLoadFinish, + onPlaceLoad = onPlaceLoad, + onError = onPlaceListError, + onBackToInitialPositionClick = onBackToInitialPositionClick, + ) + + PlaceDetailPreviewScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + placeUiState = selectedPlaceUiState, + visible = isPlacePreviewVisible, + onClick = onPlacePreviewClick, + onBackPress = onBackPress, + onError = onPlacePreviewError, + ) + + PlaceDetailPreviewSecondaryScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + placeUiState = selectedPlaceUiState, + visible = isPlaceSecondaryPreviewVisible, + onBackPress = onBackPress, + onError = onPlacePreviewError, + ) + } + } } } 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 index f657d95..03df7b2 100644 --- 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 @@ -56,7 +56,7 @@ class PlaceDetailPreviewFragment( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { FestabookTheme { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() + val placeDetailUiState by viewModel.selectedPlace.collectAsStateWithLifecycle() val visible = placeDetailUiState is PlaceUiState.Success Box( 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 index 0729421..c50d4c3 100644 --- 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 @@ -61,7 +61,7 @@ class PlaceDetailPreviewSecondaryFragment( return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val placeDetailUiState by viewModel.selectedPlaceFlow.collectAsStateWithLifecycle() + val placeDetailUiState by viewModel.selectedPlace.collectAsStateWithLifecycle() val visible = placeDetailUiState is PlaceUiState.Success LaunchedEffect(placeDetailUiState) { 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/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt index dd4d735..b65656a 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/placeDetailPreview/component/PlaceDetailPreviewSecondaryScreen.kt @@ -1,5 +1,6 @@ package com.daedan.festabook.presentation.placeMap.placeDetailPreview.component +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -34,8 +35,12 @@ fun PlaceDetailPreviewSecondaryScreen( onError: (PlaceUiState.Error) -> Unit = {}, onEmpty: () -> Unit = {}, onClick: (PlaceUiState) -> Unit = {}, + onBackPress: () -> Unit = {}, visible: Boolean = false, ) { + BackHandler(enabled = visible) { + onBackPress() + } PreviewAnimatableBox( visible = visible, modifier = diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt index 09f9e8f..5de5868 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt @@ -28,8 +28,6 @@ import com.daedan.festabook.domain.model.TimeTag 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 @@ -81,13 +79,13 @@ class PlaceListFragment( super.onCreateView(inflater, container, savedInstanceState) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val places by childViewModel.placesFlow.collectAsStateWithLifecycle() - val isExceedMaxLength by viewModel.isExceededMaxLengthFlow.collectAsStateWithLifecycle() + val places by childViewModel.places.collectAsStateWithLifecycle() + val isExceedMaxLength by viewModel.isExceededMaxLength.collectAsStateWithLifecycle() val bottomSheetState = rememberPlaceListBottomSheetState() val map by mapFlow.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - viewModel.onMapViewClickFlow.collect { + viewModel.onMapViewClick.collect { if (isGone || !isResumed || view == null) return@collect bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) } @@ -137,14 +135,6 @@ class PlaceListFragment( } } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setUpObserver() - } - override fun onPlaceClicked(place: PlaceUiModel) { Timber.d("onPlaceClicked: $place") startPlaceDetailActivity(place) @@ -179,30 +169,10 @@ class PlaceListFragment( } } - 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, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/component/PlaceListScreen.kt index 6b73ad6..ded03dc 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/placeList/component/PlaceListScreen.kt @@ -71,23 +71,27 @@ fun PlaceListScreen( val currentOnPlaceLoad by rememberUpdatedState(onPlaceLoad) Box(modifier = modifier.fillMaxSize()) { - OffsetDependentLayout( - modifier = Modifier.padding(horizontal = festabookSpacing.paddingBody1), - offset = offset, - ) { - Box { - CurrentLocationButton( - map = map, - ) - if (isExceedMaxLength) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - BackToPositionButton( - text = stringResource(R.string.map_back_to_initial_position), - onClick = onBackToInitialPositionClick, - ) + if (bottomSheetState.currentValue != PlaceListBottomSheetValue.EXPANDED) { + 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, + ) + } } } } From da97853117c7f9eb19fa99916819c926edd1b506 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Mon, 29 Dec 2025 12:42:53 +0900 Subject: [PATCH 06/22] =?UTF-8?q?refactor(PlaceMap):=20View=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장소 지도(`PlaceMap`) 화면의 Compose 마이그레이션이 완료됨에 따라, 더 이상 사용하지 않는 기존 Fragment 및 XML 레이아웃 코드를 삭제했습니다. 또한, 기능별로 분산되어 있던 Compose 컴포넌트와 관련 클래스들을 `placeMap` 패키지 하위로 통합하여 구조를 단순화했습니다. - **Legacy 코드 삭제:** - `PlaceListFragment`, `PlaceCategoryFragment`, `PlaceDetailPreviewFragment` 등 기존 View 기반의 Fragment 클래스들을 삭제했습니다. - `fragment_place_list.xml`, `item_place_list.xml` 등 관련 XML 레이아웃 리소스와 바인딩 코드를 제거했습니다. - **패키지 구조 재편:** - `placeList`, `placeCategory`, `placeDetailPreview` 등 하위 패키지에 흩어져 있던 컴포저블(`PlaceListScreen`, `PlaceCategoryScreen` 등)과 `PlaceListViewModel`을 `presentation.placeMap` 및 `presentation.placeMap.component` 패키지로 이동하여 접근성을 높였습니다. - `PlaceMapFragment` 및 `PlaceMapScreen`에서 변경된 패키지 경로를 반영하도록 import 구문을 수정했습니다. - **`CategoryView` 이동:** - 장소 상세 화면(`PlaceDetailActivity`)에서 여전히 사용되는 커스텀 뷰 `CategoryView`를 `presentation.placeDetail` 패키지로 이동하고, `activity_place_detail.xml`에서 해당 뷰를 참조하도록 수정했습니다. --- .../di/mapManager/MapManagerBindings.kt | 6 +- .../di/mapManager/MapManagerGraph.kt | 2 +- .../placeList => placeDetail}/CategoryView.kt | 2 +- .../presentation/placeMap/PlaceMapFragment.kt | 7 +- .../component/BackToPositionButton.kt | 2 +- .../component/CurrentLocationButton.kt | 2 +- .../component/OffsetDependentLayout.kt | 2 +- .../component/PlaceCategoryScreen.kt | 2 +- .../component/PlaceDetailPreviewScreen.kt | 3 +- .../PlaceDetailPreviewSecondaryScreen.kt | 2 +- .../component/PlaceListBottomSheet.kt | 2 +- .../component/PlaceListBottomSheetState.kt | 2 +- .../component/PlaceListScreen.kt | 3 +- .../placeMap/component/PlaceMapScreen.kt | 5 - .../component/PreviewAnimatableBox.kt | 2 +- .../{ => listener}/MapClickListener.kt | 2 +- .../{ => listener}/MapClickListenerImpl.kt | 3 +- .../{ => listener}/OnCameraChangeListener.kt | 2 +- .../placeMap/mapManager/MapCameraManager.kt | 2 +- .../placeMap/mapManager/MapManager.kt | 2 +- .../internal/MapCameraManagerImpl.kt | 2 +- .../internal/MapMarkerManagerImpl.kt | 2 +- .../placeCategory/PlaceCategoryFragment.kt | 84 ------ .../PlaceDetailPreviewFragment.kt | 114 -------- .../PlaceDetailPreviewSecondaryFragment.kt | 118 -------- .../placeList/OnPlaceClickListener.kt | 7 - .../placeMap/placeList/PlaceListFragment.kt | 221 --------------- .../PlaceListViewModel.kt | 6 +- .../placeMap/viewmodel/PlaceMapAction.kt | 3 + .../placeMap/viewmodel/PlaceMapEvent.kt | 3 + .../placeMap/viewmodel/PlaceMapState.kt | 3 + .../{ => viewmodel}/PlaceMapViewModel.kt | 2 +- .../main/res/layout/activity_place_detail.xml | 10 +- .../res/layout/fragment_place_category.xml | 253 ------------------ .../layout/fragment_place_detail_preview.xml | 152 ----------- ...ragment_place_detail_preview_secondary.xml | 48 ---- .../main/res/layout/fragment_place_list.xml | 128 --------- app/src/main/res/layout/item_place_list.xml | 118 -------- .../res/layout/item_place_list_skeleton.xml | 54 ---- .../placeList/PlaceListViewModelTest.kt | 2 +- .../placeList/PlaceMapViewModelTest.kt | 2 +- 41 files changed, 46 insertions(+), 1341 deletions(-) rename app/src/main/java/com/daedan/festabook/presentation/{placeMap/placeList => placeDetail}/CategoryView.kt (97%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/BackToPositionButton.kt (95%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/CurrentLocationButton.kt (87%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/OffsetDependentLayout.kt (93%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeCategory => }/component/PlaceCategoryScreen.kt (98%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PlaceDetailPreviewScreen.kt (98%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PlaceDetailPreviewSecondaryScreen.kt (98%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListBottomSheet.kt (99%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListBottomSheetState.kt (97%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => }/component/PlaceListScreen.kt (98%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeDetailPreview => }/component/PreviewAnimatableBox.kt (96%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/MapClickListener.kt (80%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/MapClickListenerImpl.kt (80%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => listener}/OnCameraChangeListener.kt (61%) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/OnPlaceClickListener.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{placeList => viewmodel}/PlaceListViewModel.kt (94%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{ => viewmodel}/PlaceMapViewModel.kt (99%) delete mode 100644 app/src/main/res/layout/fragment_place_category.xml delete mode 100644 app/src/main/res/layout/fragment_place_detail_preview.xml delete mode 100644 app/src/main/res/layout/fragment_place_detail_preview_secondary.xml delete mode 100644 app/src/main/res/layout/fragment_place_list.xml delete mode 100644 app/src/main/res/layout/item_place_list.xml delete mode 100644 app/src/main/res/layout/item_place_list_skeleton.xml 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..eb8368a 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,11 +1,11 @@ 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 +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.naver.maps.map.overlay.Marker import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo diff --git a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt index 88cfa05..4f522e0 100644 --- a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.di.mapManager -import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.mapManager.MapManager import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.naver.maps.map.NaverMap import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.Provides 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 08b968d..11a1aee 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 @@ -37,7 +37,9 @@ import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.common.toPx import com.daedan.festabook.presentation.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen +import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick @@ -53,9 +55,8 @@ import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.isSecondary -import com.daedan.festabook.presentation.placeMap.placeList.PlaceListViewModel -import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetValue -import com.daedan.festabook.presentation.placeMap.placeList.component.rememberPlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceListViewModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.AppScope 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/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/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 98% 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 e32cb8f..8e2d7bb 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,4 +1,4 @@ -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 @@ -22,7 +22,6 @@ 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.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiState 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 98% 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 b65656a..df18172 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,4 +1,4 @@ -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 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 98% 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 ded03dc..6ee054f 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,7 +39,6 @@ import com.daedan.festabook.R import com.daedan.festabook.presentation.common.component.CoilImage import com.daedan.festabook.presentation.common.component.EmptyStateScreen import com.daedan.festabook.presentation.common.component.LoadingStateScreen -import com.daedan.festabook.presentation.placeMap.component.PlaceCategoryLabel import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel 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 bfea58c..6000f51 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 @@ -17,11 +17,6 @@ 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.PlaceUiState -import com.daedan.festabook.presentation.placeMap.placeCategory.component.PlaceCategoryScreen -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewScreen -import com.daedan.festabook.presentation.placeMap.placeDetailPreview.component.PlaceDetailPreviewSecondaryScreen -import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListBottomSheetState -import com.daedan.festabook.presentation.placeMap.placeList.component.PlaceListScreen import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing import com.naver.maps.map.NaverMap 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/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 80% 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..52142ac 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,6 +1,7 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.listener import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import timber.log.Timber class MapClickListenerImpl( 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/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/placeCategory/PlaceCategoryFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt deleted file mode 100644 index ff0f011..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeCategory/PlaceCategoryFragment.kt +++ /dev/null @@ -1,84 +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.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.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 03df7b2..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewFragment.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.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -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.PlaceUiState -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() }) - - 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.selectedPlace.collectAsStateWithLifecycle() - val visible = placeDetailUiState is PlaceUiState.Success - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { - PlaceDetailPreviewScreen( - placeUiState = placeDetailUiState, - visible = visible, - modifier = - Modifier - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - onClick = { selectedPlace -> - val selectedTimeTag = viewModel.selectedTimeTag.value - if (selectedPlace !is PlaceUiState.Success || - selectedTimeTag !is PlaceUiState.Success - ) { - return@PlaceDetailPreviewScreen - } - startPlaceDetailActivity(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = - selectedPlace.value.place.title - ?: "undefined", - timeTag = selectedTimeTag.value.name, - category = selectedPlace.value.place.category.name, - ), - ) - }, - onError = { selectedPlace -> - showErrorSnackBar(selectedPlace.throwable) - }, - onBackPress = { - viewModel.unselectPlace() - }, - ) - } - } - } - } - } - - override fun onMenuItemReClick() { - viewModel.unselectPlace() - } - - 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 c50d4c3..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeDetailPreview/PlaceDetailPreviewSecondaryFragment.kt +++ /dev/null @@ -1,118 +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.PlaceUiState -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.selectedPlace.collectAsStateWithLifecycle() - val visible = placeDetailUiState is PlaceUiState.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 = { selectedPlace -> - val selectedTimeTag = viewModel.selectedTimeTag.value - if (selectedPlace !is PlaceUiState.Success || - selectedTimeTag !is PlaceUiState.Success - ) { - return@PlaceDetailPreviewSecondaryScreen - } - appGraph.defaultFirebaseLogger.log( - PlacePreviewClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeName = selectedPlace.value.place.title ?: "undefined", - timeTag = - selectedTimeTag.value.name, - category = selectedPlace.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 5de5868..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListFragment.kt +++ /dev/null @@ -1,221 +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.domain.model.TimeTag -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.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.model.PlaceUiState -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.places.collectAsStateWithLifecycle() - val isExceedMaxLength by viewModel.isExceededMaxLength.collectAsStateWithLifecycle() - val bottomSheetState = rememberPlaceListBottomSheetState() - val map by mapFlow.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.onMapViewClick.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.selectedTimeTag.collect { selectedTimeTag -> - when (selectedTimeTag) { - is PlaceUiState.Success -> { - childViewModel.updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) - } - - is PlaceUiState.Empty -> { - childViewModel.updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) - } - - else -> Unit - } - } - }, - onError = { - showErrorSnackBar(it.throwable) - }, - onBackToInitialPositionClick = { - viewModel.onBackToInitialPositionClicked() - appGraph.defaultFirebaseLogger.log( - PlaceBackToSchoolClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - }, - ) - } - } - } - - override fun onPlaceClicked(place: PlaceUiModel) { - Timber.d("onPlaceClicked: $place") - startPlaceDetailActivity(place) - val selectedTimeTag = viewModel.selectedTimeTag.value - val timeTagName = - if (selectedTimeTag is PlaceUiState.Success) selectedTimeTag.value.name else "undefined" - appGraph.defaultFirebaseLogger.log( - PlaceItemClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeId = place.id, - timeTagName = timeTagName, - 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 startPlaceDetailActivity(place: PlaceUiModel) { - viewModel.selectPlace(place.id) - } - - // 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/viewmodel/PlaceListViewModel.kt similarity index 94% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt index 480efcf..d6e4f4a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/placeList/PlaceListViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.placeList +package com.daedan.festabook.presentation.placeMap.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -37,7 +37,7 @@ class PlaceListViewModel( fun updatePlacesByCategories(category: List) { val secondaryCategories = - PlaceCategory.SECONDARY_CATEGORIES.map { + PlaceCategory.Companion.SECONDARY_CATEGORIES.map { it.toUiModel() } val primaryCategoriesSelected = category.any { it !in secondaryCategories } @@ -64,7 +64,7 @@ class PlaceListViewModel( fun updatePlacesByTimeTag(timeTagId: Long) { val filteredPlaces = - if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + if (timeTagId == TimeTag.Companion.EMTPY_TIME_TAG_ID) { cachedPlaces } else { filterPlacesByTimeTag(timeTagId) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt new file mode 100644 index 0000000..27877a3 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +class PlaceMapAction diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt new file mode 100644 index 0000000..0fef3b2 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +class PlaceMapEvent diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt new file mode 100644 index 0000000..c03ba31 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt @@ -0,0 +1,3 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +class PlaceMapState diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt similarity index 99% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt index fee618f..3374e47 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap +package com.daedan.festabook.presentation.placeMap.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index d605eb1..75f42d2 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -7,7 +7,7 @@ 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 com.daedan.festabook.presentation.placeMap.viewmodel.PlaceListViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt index 2b65e92..914ac78 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt @@ -9,13 +9,13 @@ 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.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.toUiModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk From 8aaf6668ca4275663ac9300d1d9cab9ec00d0cca Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Mon, 29 Dec 2025 18:45:40 +0900 Subject: [PATCH 07/22] =?UTF-8?q?refactor(PlaceMap):=20MVI=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 화면(`PlaceMap`)에 MVI(Model-View-Intent) 패턴을 적용하여 단방향 데이터 흐름(UDF) 구조로 리팩토링했습니다. 개별적으로 관리되던 상태들을 하나의 UI State로 통합하고, 사용자 상호작용과 일회성 이벤트를 명확히 정의하여 코드의 유지보수성을 높였습니다. - **MVI 아키텍처 구성요소 추가:** - **`PlaceMapAction`:** 지도 준비, 태그 클릭, 마커 클릭 등 사용자의 의도(Intent)를 정의하는 Sealed Interface를 추가했습니다. - **`PlaceMapEvent`:** 스낵바 표시, 화면 이동, 마커 초기화 등 일회성 부수 효과(Side Effect)를 정의하는 Sealed Interface를 추가했습니다. - **`PlaceMapUiState`:** 기존의 분산된 `StateFlow`들을 통합 관리하기 위한 단일 상태 데이터 클래스를 도입했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `onPlaceMapAction` 메서드를 통해 모든 액션을 처리하도록 로직을 중앙화했습니다. - 개별 `StateFlow` 변수들을 `_uiState`(`PlaceMapUiState`)와 `_uiEvent`(`Channel`)로 대체했습니다. - 로깅 및 데이터 로드 로직을 `Action` 처리 블록 내부로 이동시켰습니다. - **UI 상태 클래스 재정의:** - 기존 `PlaceUiState`를 삭제하고, 범용적인 로딩 상태 관리를 위한 `LoadState`로 대체했습니다. - `PlaceListUiState` 파일명을 `ListLoadState`로 변경하고 관련 참조를 수정했습니다. - **View 레이어 수정 (`PlaceMapFragment`, `PlaceMapScreen`):** - `PlaceMapScreen`의 파라미터를 개별 상태 대신 `uiState`와 `onAction` 콜백으로 단순화했습니다. - Fragment에서 `ObserveAsEvents` 유틸리티를 사용하여 `SharedFlow`/`Channel` 이벤트를 생명주기에 맞춰 안전하게 수집하도록 변경했습니다. - Compose View 내부에서 `NaverMap`의 초기화를 돕는 `rememberNaverMap` 및 `await` 확장 함수를 추가했습니다. - **테스트 코드 수정:** - 변경된 상태 클래스(`LoadState`, `ListLoadState`)에 맞춰 `PlaceListViewModelTest` 및 `PlaceMapViewModelTest`의 검증 로직을 수정했습니다. --- .../presentation/common/ObserveEvent.kt | 25 ++ .../presentation/placeMap/PlaceMapFragment.kt | 405 +++++------------ .../placeMap/component/NaverMapContent.kt | 31 +- .../component/PlaceDetailPreviewScreen.kt | 26 +- .../PlaceDetailPreviewSecondaryScreen.kt | 30 +- .../placeMap/component/PlaceListScreen.kt | 20 +- .../placeMap/component/PlaceMapScreen.kt | 129 +++--- .../placeMap/component/TimeTagMenu.kt | 10 +- .../placeMap/listener/MapClickListenerImpl.kt | 7 +- .../{PlaceListUiState.kt => ListLoadState.kt} | 10 +- .../presentation/placeMap/model/LoadState.kt | 19 + .../placeMap/model/PlaceUiState.kt | 19 - .../placeMap/viewmodel/PlaceListViewModel.kt | 94 ---- .../placeMap/viewmodel/PlaceMapAction.kt | 44 +- ...ceMapState.kt => PlaceMapActionHandler.kt} | 2 +- .../placeMap/viewmodel/PlaceMapEvent.kt | 54 ++- .../placeMap/viewmodel/PlaceMapUiState.kt | 41 ++ .../placeMap/viewmodel/PlaceMapViewModel.kt | 418 +++++++++++++----- .../placeMap/viewmodel/StateExt.kt | 14 + .../placeList/PlaceListViewModelTest.kt | 15 +- .../placeList/PlaceMapViewModelTest.kt | 18 +- 21 files changed, 775 insertions(+), 656 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/common/ObserveEvent.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/model/{PlaceListUiState.kt => ListLoadState.kt} (56%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/{PlaceMapState.kt => PlaceMapActionHandler.kt} (68%) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt 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/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index 11a1aee..df79803 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 @@ -5,12 +5,9 @@ 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.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy @@ -32,30 +29,25 @@ import com.daedan.festabook.di.mapManager.MapManagerGraph import com.daedan.festabook.domain.model.TimeTag 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.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.component.MapState import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked -import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter -import com.daedan.festabook.presentation.placeMap.logging.PlaceItemClick import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick -import com.daedan.festabook.presentation.placeMap.logging.PlacePreviewClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceTimeTagSelected import com.daedan.festabook.presentation.placeMap.mapManager.MapManager -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceListUiState +import com.daedan.festabook.presentation.placeMap.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiState -import com.daedan.festabook.presentation.placeMap.model.isSecondary -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceListViewModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapEvent import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.util.FusedLocationSource @@ -68,8 +60,6 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import timber.log.Timber @@ -91,8 +81,6 @@ class PlaceMapFragment( } private val placeMapViewModel: PlaceMapViewModel by viewModels() - private val placeListViewModel: PlaceListViewModel by viewModels() - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -107,316 +95,155 @@ class PlaceMapFragment( return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val places by placeListViewModel.places.collectAsStateWithLifecycle() - val selectedPlace by placeMapViewModel.selectedPlace.collectAsStateWithLifecycle() - val timeTags by placeMapViewModel.timeTags.collectAsStateWithLifecycle() - val selectedTimeTag by placeMapViewModel.selectedTimeTag.collectAsStateWithLifecycle() - val initialCategories = PlaceCategoryUiModel.entries - val isExceedMaxLength by placeMapViewModel.isExceededMaxLength.collectAsStateWithLifecycle() - val timeTagChanged = - placeMapViewModel.selectedTimeTag.collectAsStateWithLifecycle(viewLifecycleOwner) - var selectedCategoriesState by remember(timeTagChanged.value) { - mutableStateOf( - emptySet(), - ) - } - - val isPlacePreviewVisible by remember { - derivedStateOf { - selectedPlace is PlaceUiState.Success && - !(selectedPlace as PlaceUiState.Success).isSecondary - } - } - - val isPlaceSecondaryPreviewVisible by remember { - derivedStateOf { - selectedPlace is PlaceUiState.Success && - (selectedPlace as PlaceUiState.Success).isSecondary - } - } - - val scope = rememberCoroutineScope() - val bottomSheetState = rememberPlaceListBottomSheetState() - + val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() var mapManager by remember { mutableStateOf(null) } + val bottomSheetState = rememberPlaceListBottomSheetState() + val mapState = remember { MapState() } - LaunchedEffect(Unit) { - placeMapViewModel.placeGeographies.collect { placeGeographies -> - when (placeGeographies) { - is PlaceUiState.Loading -> Unit - is PlaceUiState.Success -> { - mapManager?.setupMarker(placeGeographies.value) - placeMapViewModel.selectedTimeTag.collect { selectedTimeTag -> - when (selectedTimeTag) { - is PlaceUiState.Success -> { - mapManager?.filterMarkersByTimeTag( - selectedTimeTag.value.timeTagId, - ) - } - - is PlaceUiState.Empty -> { - mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) - } - - else -> Unit - } - } - } - - is PlaceUiState.Error -> { - Timber.w( - placeGeographies.throwable, - "PlaceListFragment: ${placeGeographies.throwable.message}", + ObserveAsEvents(flow = placeMapViewModel.uiEvent) { event -> + when (event) { + is PlaceMapEvent.InitMap -> { + val naverMap = mapState.await() + naverMap.addOnLocationChangeListener { + binding.logger.log( + CurrentLocationChecked( + baseLogData = binding.logger.getBaseLogData(), + ), ) - showErrorSnackBar(placeGeographies.throwable) } - - else -> Unit + naverMap.locationSource = locationSource } - } - } - LaunchedEffect(Unit) { - placeMapViewModel.backToInitialPositionClicked.collect { - mapManager?.moveToPosition() - } - } + is PlaceMapEvent.InitMapManager -> { + val naverMap = mapState.await() + if (mapManager == null) { + val graph = + createGraphFactory().create( + naverMap, + event.initialMapSetting, + placeMapViewModel, + getInitialPadding(requireContext()), + ) + mapManager = graph.mapManager + mapManager?.setupBackToInitialPosition { isExceededMaxLength -> + placeMapViewModel.onPlaceMapAction( + PlaceMapAction.ExceededMaxLength(isExceededMaxLength), + ) + } + } + } - LaunchedEffect(Unit) { - placeMapViewModel.selectedCategories.collect { selectedCategories -> - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) + is PlaceMapEvent.PreloadImages -> { + preloadImages( + requireContext(), + event.places, + ) } - } - } - LaunchedEffect(Unit) { - placeMapViewModel.navigateToDetail.collect { selectedPlace -> - startPlaceDetailActivity(selectedPlace) - } - } + is PlaceMapEvent.BackToInitialPosition -> { + mapManager?.moveToPosition() + } - LaunchedEffect(Unit) { - placeListViewModel.places.first { - it is PlaceListUiState.Success || it is PlaceListUiState.Error - } - placeMapViewModel.selectedCategories.collect { selectedCategories -> - if (selectedCategories.isEmpty()) { - placeListViewModel.clearPlacesFilter() - } else { - placeListViewModel.updatePlacesByCategories(selectedCategories) + is PlaceMapEvent.MenuItemReClicked -> { + mapManager?.moveToPosition() + if (!event.isPreviewVisible) return@ObserveAsEvents + placeMapViewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) + appGraph.defaultFirebaseLogger.log( + PlaceMapButtonReClick( + baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), + ), + ) } - } - } - LaunchedEffect(Unit) { - placeMapViewModel.onMenuItemReClick.collect { - mapManager?.moveToPosition() - if (!isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible) return@collect - placeMapViewModel.onMapViewClick() - appGraph.defaultFirebaseLogger.log( - PlaceMapButtonReClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - } - } + is PlaceMapEvent.StartPlaceDetail -> { + startPlaceDetailActivity(event.placeDetail.value) + } - LaunchedEffect(Unit) { - placeMapViewModel.onMapViewClick - .filter { !isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible } - .collect { - bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + is PlaceMapEvent.ShowErrorSnackBar -> { + showErrorSnackBar(event.error.throwable) } - } - LaunchedEffect(selectedPlace) { - // 스마트 캐스팅을 위해 로컬 변수에 할당 - when (val place = selectedPlace) { - is PlaceUiState.Success -> { - mapManager?.selectMarker(place.value.place.id) + is PlaceMapEvent.SetMarkerByTimeTag -> { + if (event.isInitial) { + mapManager?.setupMarker(event.placeGeographies) + } - val currentTimeTag = placeMapViewModel.selectedTimeTag.value - val timeTagName = - if (currentTimeTag is PlaceUiState.Success) { - currentTimeTag.value.name - } else { - "undefined" + when (val selectedTimeTag = event.selectedTimeTag) { + is LoadState.Success -> { + mapManager?.filterMarkersByTimeTag( + selectedTimeTag.value.timeTagId, + ) } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = place.value.place.id, - timeTagName = timeTagName, - category = place.value.place.category.name, - ), - ) + + is LoadState.Empty -> { + mapManager?.filterMarkersByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } + + else -> Unit + } } - is PlaceUiState.Empty -> { - mapManager?.unselectMarker() + is PlaceMapEvent.FilterMapByCategory -> { + val selectedCategories = event.selectedCategories + if (selectedCategories.isEmpty()) { + mapManager?.clearFilter() + } else { + mapManager?.filterMarkersByCategories(selectedCategories) + } } - else -> Unit - } - } + is PlaceMapEvent.MapViewDrag -> { + if (event.isPreviewVisible) return@ObserveAsEvents + bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) + } - FestabookTheme { - PlaceMapScreen( - places = places, - selectedPlaceUiState = selectedPlace, - timeTagsState = timeTags, - selectedTimeTagState = selectedTimeTag, - onMapReady = { map -> - scope.launch { - map.addOnLocationChangeListener { + is PlaceMapEvent.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" + } binding.logger.log( - CurrentLocationChecked( + PlaceMarkerClick( baseLogData = binding.logger.getBaseLogData(), + placeId = place.value.place.id, + timeTagName = timeTagName, + category = place.value.place.category.name, ), ) } - map.locationSource = locationSource - placeMapViewModel.initialMapSetting.collect { initialMapSetting -> - if (initialMapSetting !is PlaceUiState.Success) return@collect - if (mapManager == null) { - val graph = - createGraphFactory().create( - map, - initialMapSetting.value, - placeMapViewModel, - getInitialPadding(requireContext()), - ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - placeMapViewModel.setIsExceededMaxLength( - isExceededMaxLength, - ) - } - } - } + + else -> Unit } - }, - onTimeTagClick = { timeTag -> - placeMapViewModel.onDaySelected(timeTag) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = timeTag.name, - ), - ) - }, - onPlaceClick = { place -> - Timber.d("onPlaceClicked: $place") - startPlaceDetailActivity(place) - val selectedTimeTag = placeMapViewModel.selectedTimeTag.value - val timeTagName = - if (selectedTimeTag is PlaceUiState.Success) selectedTimeTag.value.name else "undefined" - appGraph.defaultFirebaseLogger.log( - PlaceItemClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - placeId = place.id, - timeTagName = timeTagName, - category = place.category.name, - ), - ) - }, - onPlaceLoad = { - placeMapViewModel.selectedTimeTag.collect { selectedTimeTag -> - when (selectedTimeTag) { - is PlaceUiState.Success -> { - placeListViewModel.updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) - } + } - is PlaceUiState.Empty -> { - placeListViewModel.updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) - } + is PlaceMapEvent.UnselectMarker -> { + mapManager?.unselectMarker() + } + } + } - else -> Unit - } - } - }, - isExceedMaxLength = isExceedMaxLength, - onPlaceLoadFinish = { places -> - preloadImages( - requireContext(), - places, - ) - }, - onPlaceListError = { - showErrorSnackBar(it.throwable) - }, - onBackToInitialPositionClick = { - placeMapViewModel.onBackToInitialPositionClicked() - appGraph.defaultFirebaseLogger.log( - PlaceBackToSchoolClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - }, - onCategoryClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - placeMapViewModel.unselectPlace() - placeMapViewModel.setSelectedCategories(selectedCategories.toList()) - appGraph.defaultFirebaseLogger.log( - PlaceCategoryClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - currentCategories = selectedCategories.joinToString(",") { it.toString() }, - ), - ) - }, - onDisplayAllClick = { selectedCategories -> - selectedCategoriesState = selectedCategories - placeMapViewModel.unselectPlace() - placeMapViewModel.setSelectedCategories(initialCategories) - }, - isPlacePreviewVisible = isPlacePreviewVisible, - isPlaceSecondaryPreviewVisible = isPlaceSecondaryPreviewVisible, - onMapDrag = { - placeMapViewModel.onMapViewClick() - }, - selectedCategoriesState = selectedCategoriesState, - initialCategories = initialCategories, + FestabookTheme { + PlaceMapScreen( + uiState = uiState, + onAction = { placeMapViewModel.onPlaceMapAction(it) }, bottomSheetState = bottomSheetState, - onBackPress = { - placeMapViewModel.unselectPlace() - }, - onPlacePreviewClick = { selectedPlace -> - val selectedTimeTag = placeMapViewModel.selectedTimeTag.value - if (selectedPlace is PlaceUiState.Success && - selectedTimeTag is PlaceUiState.Success - ) { - startPlaceDetailActivity(selectedPlace.value) - binding.logger.log( - PlacePreviewClick( - baseLogData = binding.logger.getBaseLogData(), - placeName = - selectedPlace.value.place.title - ?: "undefined", - timeTag = selectedTimeTag.value.name, - category = selectedPlace.value.place.category.name, - ), - ) - } - }, - onPlacePreviewError = { - showErrorSnackBar(it.throwable) - }, + mapState = mapState, ) } } } } - private fun startPlaceDetailActivity(place: PlaceUiModel) { - placeMapViewModel.selectPlace(place.id) - } - override fun onMenuItemReClick() { - placeMapViewModel.unselectPlace() - placeMapViewModel.onMenuItemReClick() + placeMapViewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) + placeMapViewModel.onMenuItemReClicked() } private fun startPlaceDetailActivity(placeDetail: PlaceDetailUiModel) { 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 73da5e5..f3b1176 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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -22,27 +23,51 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.naver.maps.map.MapView import com.naver.maps.map.NaverMap +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @Composable fun NaverMapContent( modifier: Modifier = Modifier, + mapState: MapState = MapState(), onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, content: @Composable (NaverMap?) -> Unit, ) { val context = LocalContext.current val mapView = remember { MapView(context) } - var naverMap by remember { mutableStateOf(null) } LaunchedEffect(mapView) { - naverMap = mapView.getMapAndRunCallback(onMapReady) + val naverMap = mapView.getMapAndRunCallback(onMapReady) + mapState.initMap(naverMap) } AndroidView( factory = { mapView }, modifier = modifier.dragInterceptor(onMapDrag), ) RegisterMapLifeCycle(mapView) - content(naverMap) + content(mapState.value) +} + +class MapState { + 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() + } } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 8e2d7bb..32e23e7 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -22,9 +22,9 @@ 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.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.FestabookTypography @@ -33,11 +33,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewScreen( - placeUiState: PlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, visible: Boolean = false, - onClick: (PlaceUiState) -> Unit = {}, - onError: (PlaceUiState.Error) -> Unit = {}, + onClick: (LoadState) -> Unit = {}, + onError: (LoadState.Error) -> Unit = {}, onBackPress: () -> Unit = {}, ) { BackHandler(enabled = visible) { @@ -48,16 +48,16 @@ fun PlaceDetailPreviewScreen( modifier = modifier .wrapContentSize() - .clickable { onClick(placeUiState) }, + .clickable { onClick(selectedPlace) }, ) { - when (placeUiState) { - is PlaceUiState.Loading -> Unit - is PlaceUiState.Success -> { - PlaceDetailPreviewContent(placeDetail = placeUiState.value) + when (selectedPlace) { + is LoadState.Loading -> Unit + is LoadState.Success -> { + PlaceDetailPreviewContent(placeDetail = selectedPlace.value) } - is PlaceUiState.Error -> onError(placeUiState) - is PlaceUiState.Empty -> Unit + is LoadState.Error -> onError(selectedPlace) + is LoadState.Empty -> Unit } } } @@ -187,8 +187,8 @@ private fun PlaceDetailPreviewScreenPreview() { modifier = Modifier .padding(festabookSpacing.paddingScreenGutter), - placeUiState = - PlaceUiState.Success( + selectedPlace = + LoadState.Success( value = FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index df18172..90be06a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -18,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.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.getIconId import com.daedan.festabook.presentation.placeMap.model.getTextId import com.daedan.festabook.presentation.theme.FestabookTheme @@ -30,11 +30,11 @@ import com.daedan.festabook.presentation.theme.festabookSpacing @Composable fun PlaceDetailPreviewSecondaryScreen( - placeUiState: PlaceUiState, + selectedPlace: LoadState, modifier: Modifier = Modifier, - onError: (PlaceUiState.Error) -> Unit = {}, + onError: (LoadState.Error) -> Unit = {}, onEmpty: () -> Unit = {}, - onClick: (PlaceUiState) -> Unit = {}, + onClick: (LoadState) -> Unit = {}, onBackPress: () -> Unit = {}, visible: Boolean = false, ) { @@ -47,15 +47,15 @@ fun PlaceDetailPreviewSecondaryScreen( modifier .fillMaxWidth() .clickable { - onClick(placeUiState) + onClick(selectedPlace) }, shape = festabookShapes.radius2, ) { - when (placeUiState) { - is PlaceUiState.Loading -> Unit - is PlaceUiState.Error -> onError(placeUiState) - is PlaceUiState.Empty -> onEmpty() - is PlaceUiState.Success -> { + when (selectedPlace) { + is LoadState.Loading -> Unit + is LoadState.Error -> onError(selectedPlace) + is LoadState.Empty -> onEmpty() + is LoadState.Success -> { Row( modifier = Modifier.padding( @@ -68,7 +68,7 @@ fun PlaceDetailPreviewSecondaryScreen( modifier = Modifier.size(24.dp), painter = painterResource( - placeUiState.value.place.category + selectedPlace.value.place.category .getIconId(), ), tint = Color.Unspecified, @@ -78,9 +78,9 @@ 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, @@ -98,8 +98,8 @@ private fun PlaceDetailPreviewSecondaryScreenPreview() { PlaceDetailPreviewSecondaryScreen( visible = true, modifier = Modifier.padding(horizontal = festabookSpacing.paddingScreenGutter), - placeUiState = - PlaceUiState.Success( + selectedPlace = + LoadState.Success( FAKE_PLACE_DETAIL, ), ) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 6ee054f..3d4a174 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -39,8 +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.model.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 @@ -50,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, @@ -61,7 +61,7 @@ fun PlaceListScreen( onPlaceClick: (place: PlaceUiModel) -> Unit = {}, onPlaceLoadFinish: (places: List) -> Unit = {}, onPlaceLoad: suspend () -> Unit = {}, - onError: (PlaceListUiState.Error>) -> Unit = {}, + onError: (ListLoadState.Error>) -> Unit = {}, onBackToInitialPositionClick: () -> Unit = {}, ) { val listState = rememberLazyListState() @@ -81,7 +81,7 @@ fun PlaceListScreen( CurrentLocationButton( map = map, ) - if (isExceedMaxLength) { + if (isExceededMaxLength) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -120,19 +120,19 @@ fun PlaceListScreen( }, ) { when (placesUiState) { - is PlaceListUiState.Loading -> + is ListLoadState.Loading -> LoadingStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) - is PlaceListUiState.Error -> { + is ListLoadState.Error -> { onError(placesUiState) EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) } - is PlaceListUiState.Success -> { + is ListLoadState.Success -> { onPlaceLoadFinish(placesUiState.value) if (placesUiState.value.isEmpty()) { EmptyStateScreen( @@ -148,7 +148,7 @@ fun PlaceListScreen( } } - is PlaceListUiState.PlaceLoaded -> { + is ListLoadState.PlaceLoaded -> { LaunchedEffect(Unit) { scope.launch { currentOnPlaceLoad() @@ -284,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 6000f51..3bb5d13 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 @@ -11,56 +11,34 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -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.PlaceUiState +import com.daedan.festabook.presentation.placeMap.model.LoadState +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.festabookSpacing -import com.naver.maps.map.NaverMap @Composable fun PlaceMapScreen( - places: PlaceListUiState>, - initialCategories: List, - selectedCategoriesState: Set, - selectedPlaceUiState: PlaceUiState, - timeTagsState: PlaceUiState>, - selectedTimeTagState: PlaceUiState, - onMapReady: (NaverMap) -> Unit, - onTimeTagClick: (TimeTag) -> Unit, - onMapDrag: () -> Unit, - onPlaceClick: (PlaceUiModel) -> Unit, - onPlacePreviewClick: (PlaceUiState) -> Unit, - onBackPress: () -> Unit, - onPlacePreviewError: (PlaceUiState.Error) -> Unit, - isExceedMaxLength: Boolean, - onPlaceLoadFinish: (List) -> Unit, - onPlaceLoad: suspend () -> Unit, - onPlaceListError: (PlaceListUiState.Error>) -> Unit, - onBackToInitialPositionClick: () -> Unit, - onCategoryClick: (Set) -> Unit, - onDisplayAllClick: (Set) -> Unit, - isPlacePreviewVisible: Boolean, - isPlaceSecondaryPreviewVisible: Boolean, + uiState: PlaceMapUiState, + onAction: (PlaceMapAction) -> Unit, bottomSheetState: PlaceListBottomSheetState, + mapState: MapState, modifier: Modifier = Modifier, ) { NaverMapContent( modifier = modifier.fillMaxSize(), - onMapReady = onMapReady, - onMapDrag = onMapDrag, + mapState = mapState, + onMapReady = { onAction(PlaceMapAction.OnMapReady) }, + onMapDrag = { onAction(PlaceMapAction.OnMapDrag) }, ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), ) { TimeTagMenu( - timeTagsState = timeTagsState, - selectedTimeTagState = selectedTimeTagState, + timeTagsState = uiState.timeTags, + selectedTimeTagState = uiState.selectedTimeTag, onTimeTagClick = { timeTag -> - onTimeTagClick(timeTag) + onAction(PlaceMapAction.OnTimeTagClick(timeTag)) }, modifier = Modifier @@ -69,10 +47,10 @@ fun PlaceMapScreen( ).padding(horizontal = 24.dp), ) PlaceCategoryScreen( - initialCategories = initialCategories, - selectedCategories = selectedCategoriesState, - onCategoryClick = onCategoryClick, - onDisplayAllClick = onDisplayAllClick, + initialCategories = uiState.initialCategories, + selectedCategories = uiState.selectedCategories, + onCategoryClick = { onAction(PlaceMapAction.OnCategoryClick(it)) }, + onDisplayAllClick = { onAction(PlaceMapAction.OnCategoryClick(it)) }, ) Box( @@ -88,47 +66,52 @@ fun PlaceMapScreen( PlaceListScreen( modifier = Modifier.alpha( - if (!isPlacePreviewVisible && !isPlaceSecondaryPreviewVisible) 1f else 0f, + if (uiState.selectedPlace is LoadState.Empty) { + 1f + } else { + 0f + }, ), - placesUiState = places, + placesUiState = uiState.places, map = naverMap, - onPlaceClick = onPlaceClick, + onPlaceClick = { onAction(PlaceMapAction.OnPlaceClick(it.id)) }, bottomSheetState = bottomSheetState, - isExceedMaxLength = isExceedMaxLength, - onPlaceLoadFinish = onPlaceLoadFinish, - onPlaceLoad = onPlaceLoad, - onError = onPlaceListError, - onBackToInitialPositionClick = onBackToInitialPositionClick, + isExceededMaxLength = uiState.isExceededMaxLength, + onPlaceLoadFinish = { onAction(PlaceMapAction.OnPlaceLoadFinish(it)) }, + onPlaceLoad = { onAction(PlaceMapAction.OnPlaceLoad) }, + onBackToInitialPositionClick = { onAction(PlaceMapAction.OnBackToInitialPositionClick) }, ) - PlaceDetailPreviewScreen( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - placeUiState = selectedPlaceUiState, - visible = isPlacePreviewVisible, - onClick = onPlacePreviewClick, - onBackPress = onBackPress, - onError = onPlacePreviewError, - ) + if (uiState.isPlacePreviewVisible) { + PlaceDetailPreviewScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onClick = { onAction(PlaceMapAction.OnPlacePreviewClick(it)) }, + onBackPress = { onAction(PlaceMapAction.OnBackPress) }, + ) + } - PlaceDetailPreviewSecondaryScreen( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding( - vertical = festabookSpacing.paddingBody4, - horizontal = festabookSpacing.paddingScreenGutter, - ), - placeUiState = selectedPlaceUiState, - visible = isPlaceSecondaryPreviewVisible, - onBackPress = onBackPress, - onError = onPlacePreviewError, - ) + if (uiState.isPlaceSecondaryPreviewVisible) { + PlaceDetailPreviewSecondaryScreen( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + vertical = festabookSpacing.paddingBody4, + horizontal = festabookSpacing.paddingScreenGutter, + ), + selectedPlace = uiState.selectedPlace, + visible = true, + onBackPress = { onAction(PlaceMapAction.OnBackPress) }, + ) + } } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index 12d82ab..0608c6e 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -41,7 +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.model.PlaceUiState +import com.daedan.festabook.presentation.placeMap.model.LoadState import com.daedan.festabook.presentation.theme.FestabookColor import com.daedan.festabook.presentation.theme.FestabookTheme import com.daedan.festabook.presentation.theme.festabookShapes @@ -52,14 +52,14 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimeTagMenu( - timeTagsState: PlaceUiState>, - selectedTimeTagState: PlaceUiState, + timeTagsState: LoadState>, + selectedTimeTagState: LoadState, modifier: Modifier = Modifier, onTimeTagClick: (TimeTag) -> Unit = {}, ) { when (timeTagsState) { - is PlaceUiState.Success -> { - if (selectedTimeTagState !is PlaceUiState.Success) return + is LoadState.Success -> { + if (selectedTimeTagState !is LoadState.Success) return TimeTagContent( title = selectedTimeTagState.value.name, timeTags = timeTagsState.value, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt index 52142ac..75d9253 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt @@ -1,6 +1,7 @@ package com.daedan.festabook.presentation.placeMap.listener import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import timber.log.Timber @@ -12,12 +13,14 @@ class MapClickListenerImpl( category: PlaceCategoryUiModel, ): Boolean { Timber.d("Marker CLick : placeID: $placeId categoty: $category") - viewModel.selectPlace(placeId) + viewModel.onPlaceMapAction( + PlaceMapAction.OnPlaceClick(placeId), + ) return true } override fun onMapClickListener() { Timber.d("Map CLick") - viewModel.unselectPlace() + viewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) } } 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/ListLoadState.kt similarity index 56% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/model/ListLoadState.kt index 561e744..1bad571 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceListUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/ListLoadState.kt @@ -1,17 +1,17 @@ package com.daedan.festabook.presentation.placeMap.model -sealed interface PlaceListUiState { - class Loading : PlaceListUiState +sealed interface ListLoadState { + class Loading : ListLoadState data class Success( val value: T, - ) : PlaceListUiState + ) : ListLoadState data class PlaceLoaded( val value: List, - ) : PlaceListUiState> + ) : ListLoadState> data class Error( val throwable: Throwable, - ) : PlaceListUiState + ) : ListLoadState } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt new file mode 100644 index 0000000..a0f9162 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook.presentation.placeMap.model + +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel + +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.SECONDARY_CATEGORIES diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt deleted file mode 100644 index 9747e07..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/PlaceUiState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.model - -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel - -sealed interface PlaceUiState { - data object Loading : PlaceUiState - - data object Empty : PlaceUiState - - data class Success( - val value: T, - ) : PlaceUiState - - data class Error( - val throwable: Throwable, - ) : PlaceUiState -} - -val PlaceUiState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt deleted file mode 100644 index d6e4f4a..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceListViewModel.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel - -import androidx.lifecycle.ViewModel -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.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -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: MutableStateFlow>> = - MutableStateFlow(PlaceListUiState.Loading()) - val places: StateFlow>> = _places.asStateFlow() - - init { - loadAllPlaces() - } - - fun updatePlacesByCategories(category: List) { - val secondaryCategories = - PlaceCategory.Companion.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.Companion.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/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt index 27877a3..405c087 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt @@ -1,3 +1,45 @@ package com.daedan.festabook.presentation.placeMap.viewmodel -class PlaceMapAction +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.presentation.placeMap.model.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface PlaceMapAction { + data object OnMapReady : PlaceMapAction + + data class OnTimeTagClick( + val timeTag: TimeTag, + ) : PlaceMapAction + + data object OnMapDrag : PlaceMapAction + + data class OnPlaceClick( + val placeId: Long, + ) : PlaceMapAction + + data class OnPlacePreviewClick( + val place: LoadState, + ) : PlaceMapAction + + data object OnBackPress : PlaceMapAction + + data class OnPlaceLoadFinish( + val places: List, + ) : PlaceMapAction + + data object OnBackToInitialPositionClick : PlaceMapAction + + data class OnCategoryClick( + val categories: Set, + ) : PlaceMapAction + + data object OnPlaceLoad : PlaceMapAction + + data class ExceededMaxLength( + val isExceededMaxLength: Boolean, + ) : PlaceMapAction + + data object UnSelectPlace : PlaceMapAction +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt similarity index 68% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt index c03ba31..7d309cd 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt @@ -1,3 +1,3 @@ package com.daedan.festabook.presentation.placeMap.viewmodel -class PlaceMapState +class PlaceMapActionHandler diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt index 0fef3b2..18eeea0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt @@ -1,3 +1,55 @@ package com.daedan.festabook.presentation.placeMap.viewmodel -class PlaceMapEvent +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.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel + +sealed interface PlaceMapEvent { + data object InitMap : PlaceMapEvent + + data class InitMapManager( + val initialMapSetting: InitialMapSettingUiModel, + ) : 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 object BackToInitialPosition : PlaceMapEvent + + data class MenuItemReClicked( + val isPreviewVisible: Boolean, + ) : PlaceMapEvent + + data class SetMarkerByTimeTag( + val placeGeographies: List, + val selectedTimeTag: LoadState, + val isInitial: Boolean, + ) : PlaceMapEvent + + data class FilterMapByCategory( + val selectedCategories: List, + ) : PlaceMapEvent + + data class MapViewDrag( + val isPreviewVisible: Boolean, + ) : PlaceMapEvent + + data class SelectMarker( + val placeDetail: LoadState, + ) : PlaceMapEvent + + data object UnselectMarker : PlaceMapEvent +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt new file mode 100644 index 0000000..2840ba4 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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.ListLoadState +import com.daedan.festabook.presentation.placeMap.model.LoadState +import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.model.isSecondary + +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/viewmodel/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt index 3374e47..202f56a 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt @@ -3,26 +3,40 @@ package com.daedan.festabook.presentation.placeMap.viewmodel import androidx.lifecycle.ViewModel 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.PlaceDetailRepository import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.presentation.common.Event -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel +import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.placeDetail.model.toUiModel +import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick +import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick +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.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.ListLoadState +import com.daedan.festabook.presentation.placeMap.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiState +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.MutableSharedFlow +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -31,66 +45,156 @@ import kotlinx.coroutines.launch class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, private val placeDetailRepository: PlaceDetailRepository, + private val logger: DefaultFirebaseLogger, ) : ViewModel() { - private val _initialMapSetting: MutableStateFlow> = - MutableStateFlow(PlaceUiState.Loading) - val initialMapSetting: StateFlow> = - _initialMapSetting.asStateFlow() + private var cachedPlaces = listOf() + private var cachedPlaceByTimeTag: List = emptyList() - private val _placeGeographies: MutableStateFlow>> = - MutableStateFlow(PlaceUiState.Loading) - val placeGeographies: StateFlow>> = - _placeGeographies.asStateFlow() + private val _uiState = MutableStateFlow(PlaceMapUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _timeTags = MutableStateFlow>>(PlaceUiState.Empty) - val timeTags: StateFlow>> = _timeTags.asStateFlow() + private val _uiEvent = Channel() + val uiEvent: Flow = _uiEvent.receiveAsFlow() - private val _selectedTimeTag = MutableStateFlow>(PlaceUiState.Empty) - val selectedTimeTag: StateFlow> = _selectedTimeTag.asStateFlow() + init { + loadOrganizationGeography() + loadTimeTags() + loadAllPlaces() + waitEvent() + } - private val _selectedPlace: MutableStateFlow> = - MutableStateFlow(PlaceUiState.Loading) - val selectedPlace: StateFlow> = _selectedPlace.asStateFlow() + fun onPlaceMapAction(action: PlaceMapAction) { + viewModelScope.launch { + when (action) { + is PlaceMapAction.OnMapReady -> { + _uiEvent.send(PlaceMapEvent.InitMap) + val setting = + uiState.await> { it.initialMapSetting } + _uiEvent.send(PlaceMapEvent.InitMapManager(setting.value)) + } - private val _navigateToDetail = - MutableSharedFlow( - extraBufferCapacity = 1, - ) - val navigateToDetail: SharedFlow = _navigateToDetail.asSharedFlow() + is PlaceMapAction.OnTimeTagClick -> { + onDaySelected(action.timeTag) + logger.log( + PlaceTimeTagSelected( + baseLogData = logger.getBaseLogData(), + timeTagName = action.timeTag.name, + ), + ) + } - private val _isExceededMaxLength: MutableStateFlow = MutableStateFlow(false) - val isExceededMaxLength: StateFlow = _isExceededMaxLength.asStateFlow() + is PlaceMapAction.OnPlaceClick -> { + selectPlace(action.placeId) + } - private val _backToInitialPositionClicked: MutableSharedFlow> = - MutableSharedFlow( - extraBufferCapacity = 1, - ) - val backToInitialPositionClicked: SharedFlow> = - _backToInitialPositionClicked.asSharedFlow() + is PlaceMapAction.OnPlaceLoad -> { + val selectedTimeTag = + uiState + .map { it.selectedTimeTag } + .distinctUntilChanged() + .first() - private val _selectedCategories: MutableStateFlow> = - MutableStateFlow( - PlaceCategoryUiModel.entries, - ) - val selectedCategories: StateFlow> = - _selectedCategories.asStateFlow() + when (selectedTimeTag) { + is LoadState.Success -> { + updatePlacesByTimeTag(selectedTimeTag.value.timeTagId) + } - private val _onMapViewClick: MutableSharedFlow> = - MutableSharedFlow( - extraBufferCapacity = 1, - ) - val onMapViewClick: SharedFlow> = _onMapViewClick.asSharedFlow() + is LoadState.Empty -> { + updatePlacesByTimeTag(TimeTag.EMTPY_TIME_TAG_ID) + } - private val _onMenuItemReClick: MutableSharedFlow> = - MutableSharedFlow( - extraBufferCapacity = 1, - ) + else -> Unit + } + } - val onMenuItemReClick: SharedFlow> = _onMenuItemReClick.asSharedFlow() + is PlaceMapAction.OnPlaceLoadFinish -> + _uiEvent.send( + PlaceMapEvent.PreloadImages( + action.places, + ), + ) - init { - loadOrganizationGeography() - loadTimeTags() + is PlaceMapAction.OnBackToInitialPositionClick -> { + logger.log( + PlaceBackToSchoolClick( + baseLogData = logger.getBaseLogData(), + ), + ) + _uiEvent.send(PlaceMapEvent.BackToInitialPosition) + } + + is PlaceMapAction.OnCategoryClick -> { + uiState.await> { it.places } + unselectPlace() + updatePlacesByCategories(action.categories.toList()) + + _uiState.update { + it.copy(selectedCategories = action.categories) + } + + _uiEvent.send(PlaceMapEvent.FilterMapByCategory(action.categories.toList())) + + logger.log( + PlaceCategoryClick( + baseLogData = logger.getBaseLogData(), + currentCategories = action.categories.joinToString(",") { it.toString() }, + ), + ) + } + + is PlaceMapAction.OnMapDrag -> { + _uiEvent.send( + PlaceMapEvent.MapViewDrag( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) + } + + is PlaceMapAction.OnBackPress -> { + unselectPlace() + } + + is PlaceMapAction.OnPlacePreviewClick -> { + val selectedTimeTag = uiState.value.selectedTimeTag + val selectedPlace = action.place + if (selectedPlace is LoadState.Success && + selectedTimeTag is LoadState.Success + ) { + _uiEvent.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 PlaceMapAction.ExceededMaxLength -> { + _uiState.update { + it.copy( + isExceededMaxLength = action.isExceededMaxLength, + ) + } + } + + is PlaceMapAction.UnSelectPlace -> { + unselectPlace() + } + } + } + } + + fun onMenuItemReClicked() { + _uiEvent.trySend( + PlaceMapEvent.MenuItemReClicked( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) } private fun loadTimeTags() { @@ -98,97 +202,195 @@ class PlaceMapViewModel( placeListRepository .getTimeTags() .onSuccess { timeTags -> - _timeTags.tryEmit( - PlaceUiState.Success(timeTags), - ) + _uiState.update { + it.copy( + timeTags = LoadState.Success(timeTags), + ) + } }.onFailure { - _timeTags.tryEmit(PlaceUiState.Empty) + _uiState.update { + it.copy( + timeTags = LoadState.Empty, + ) + } } // 기본 선택값 - val timeTags = timeTags.value - if (timeTags is PlaceUiState.Success && timeTags.value.isNotEmpty()) { - _selectedTimeTag.tryEmit( - PlaceUiState.Success( + val timeTags = uiState.value.timeTags + val selectedTimeTag = + if (timeTags is LoadState.Success && timeTags.value.isNotEmpty()) { + LoadState.Success( timeTags.value.first(), - ), - ) - } else { - _selectedTimeTag.tryEmit(PlaceUiState.Empty) + ) + } else { + LoadState.Empty + } + _uiState.update { + it.copy(selectedTimeTag = selectedTimeTag) } + + val placeGeographies = + uiState.await>> { it.placeGeographies } + _uiEvent.send( + PlaceMapEvent.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = selectedTimeTag, + isInitial = true, + ), + ) } } - fun onDaySelected(item: TimeTag) { + private fun onDaySelected(item: TimeTag) { unselectPlace() - _selectedTimeTag.tryEmit( - PlaceUiState.Success(item), - ) + _uiState.update { + it.copy(selectedTimeTag = LoadState.Success(item)) + } + viewModelScope.launch { + val placeGeographies = + uiState.await>> { it.placeGeographies } + _uiEvent.send( + PlaceMapEvent.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = LoadState.Success(item), + isInitial = false, + ), + ) + } } - fun selectPlace(placeId: Long) { + private fun selectPlace(placeId: Long) { viewModelScope.launch { - _selectedPlace.value = PlaceUiState.Loading + _uiState.update { it.copy(selectedPlace = LoadState.Loading) } placeDetailRepository .getPlaceDetail(placeId = placeId) - .onSuccess { - _selectedPlace.value = PlaceUiState.Success(it.toUiModel()) - }.onFailure { - _selectedPlace.value = PlaceUiState.Error(it) + .onSuccess { item -> + _uiState.update { + it.copy(selectedPlace = LoadState.Success(item.toUiModel())) + } + _uiEvent.send(PlaceMapEvent.SelectMarker(uiState.value.selectedPlace)) + 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 -> + _uiState.update { it.copy(selectedPlace = LoadState.Error(item)) } } } } - fun unselectPlace() { - _selectedPlace.value = PlaceUiState.Empty + private fun unselectPlace() { + _uiState.update { it.copy(selectedPlace = LoadState.Empty) } + _uiEvent.trySend(PlaceMapEvent.UnselectMarker) } - fun onExpandedStateReached() { - val currentPlace = _selectedPlace.value.let { it as? PlaceUiState.Success }?.value - if (currentPlace != null) { - _navigateToDetail.tryEmit(currentPlace) + private fun loadOrganizationGeography() { + viewModelScope.launch { + placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> + _uiState.update { + it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) + } + } + + launch { + placeListRepository + .getPlaceGeographies() + .onSuccess { placeGeographies -> + _uiState.update { + it.copy( + placeGeographies = LoadState.Success(placeGeographies.map { it.toUiModel() }), + ) + } + }.onFailure { item -> + _uiState.update { + it.copy(placeGeographies = LoadState.Error(item)) + } + } + } } } - fun onBackToInitialPositionClicked() { - _backToInitialPositionClicked.tryEmit(Event(Unit)) - } + private fun updatePlacesByCategories(category: List) { + if (category.isEmpty()) { + clearPlacesFilter() + return + } - fun setIsExceededMaxLength(isExceededMaxLength: Boolean) { - _isExceededMaxLength.value = isExceededMaxLength + 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 + } + _uiState.update { it.copy(places = ListLoadState.Success(filteredPlaces)) } } - fun setSelectedCategories(categories: List) { - _selectedCategories.value = categories + private fun filterPlacesByTimeTag(timeTagId: Long): List { + val filteredPlaces = + cachedPlaces.filter { place -> + place.timeTagId.contains(timeTagId) + } + return filteredPlaces } - fun onMapViewClick() { - _onMapViewClick.tryEmit(Event(Unit)) + private fun updatePlacesByTimeTag(timeTagId: Long) { + val filteredPlaces = + if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { + cachedPlaces + } else { + filterPlacesByTimeTag(timeTagId) + } + + _uiState.update { it.copy(places = ListLoadState.Success(filteredPlaces)) } + cachedPlaceByTimeTag = filteredPlaces } - fun onMenuItemReClick() { - _onMenuItemReClick.tryEmit(Event(Unit)) + private fun clearPlacesFilter() { + _uiState.update { it.copy(places = ListLoadState.Success(cachedPlaceByTimeTag)) } } - private fun loadOrganizationGeography() { + private fun loadAllPlaces() { viewModelScope.launch { - placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _initialMapSetting.tryEmit( - PlaceUiState.Success(organizationGeography.toUiModel()), - ) - } + val result = placeListRepository.getPlaces() + result + .onSuccess { places -> + val placeUiModels = places.map { it.toUiModel() } + cachedPlaces = placeUiModels + _uiState.update { it.copy(places = ListLoadState.PlaceLoaded(placeUiModels)) } + }.onFailure { error -> + _uiState.update { it.copy(places = ListLoadState.Error(error)) } + } + } + } + @OptIn(FlowPreview::class) + private fun waitEvent() { + viewModelScope.launch { launch { - placeListRepository - .getPlaceGeographies() - .onSuccess { placeGeographies -> - _placeGeographies.tryEmit( - PlaceUiState.Success(placeGeographies.map { it.toUiModel() }), - ) - }.onFailure { - _placeGeographies.tryEmit( - PlaceUiState.Error(it), - ) + uiState + .map { it.hasAnyError } + .distinctUntilChanged() + .filterIsInstance() + .debounce(1000) + .collect { + _uiEvent.send(PlaceMapEvent.ShowErrorSnackBar(it)) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt new file mode 100644 index 0000000..8da0e7d --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt @@ -0,0 +1,14 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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 + +suspend inline fun StateFlow.await(crossinline selector: (PlaceMapUiState) -> Any?): R = + this + .map { selector(it) } + .distinctUntilChanged() + .filterIsInstance() + .first() diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index 75f42d2..0d69d00 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -4,10 +4,9 @@ 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.ListLoadState 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.viewmodel.PlaceListViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -70,7 +69,7 @@ class PlaceListViewModelTest { val expected = FAKE_PLACES.map { it.toUiModel() } val actual = placeListViewModel.places.getOrAwaitValue() coVerify { placeListRepository.getPlaces() } - assertThat(actual).isEqualTo(PlaceListUiState.PlaceLoaded(expected)) + assertThat(actual).isEqualTo(ListLoadState.PlaceLoaded(expected)) } @Test @@ -90,7 +89,7 @@ class PlaceListViewModelTest { .filter { it.category.toUiModel() in targetCategories } .map { it.toUiModel() } val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -107,7 +106,7 @@ class PlaceListViewModelTest { // then val expected = FAKE_PLACES.map { it.toUiModel() } val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -125,7 +124,7 @@ class PlaceListViewModelTest { // then val expected = FAKE_PLACES.map { it.toUiModel() } val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -142,7 +141,7 @@ class PlaceListViewModelTest { // then val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -157,6 +156,6 @@ class PlaceListViewModelTest { // then val actual = placeListViewModel.places.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.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 index 914ac78..936bf8a 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt @@ -10,10 +10,10 @@ 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.model.InitialMapSettingUiModel +import com.daedan.festabook.presentation.placeMap.model.ListLoadState +import com.daedan.festabook.presentation.placeMap.model.LoadState 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.PlaceUiState import com.daedan.festabook.presentation.placeMap.model.toUiModel import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import io.mockk.coEvery @@ -124,7 +124,7 @@ class PlaceMapViewModelTest { val expected = FAKE_PLACE_GEOGRAPHIES.map { it.toUiModel() } val actual = placeMapViewModel.placeGeographies.getOrAwaitValue() coVerify { placeListRepository.getPlaceGeographies() } - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -143,7 +143,7 @@ class PlaceMapViewModelTest { // then val expected = FAKE_ORGANIZATION_GEOGRAPHY.toUiModel() val actual = placeMapViewModel.initialMapSetting.getOrAwaitValue() - assertThat(actual).isEqualTo(PlaceListUiState.Success(expected)) + assertThat(actual).isEqualTo(ListLoadState.Success(expected)) } @Test @@ -164,10 +164,10 @@ class PlaceMapViewModelTest { // then val expected2 = - PlaceListUiState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) + ListLoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) val actual2 = placeMapViewModel.initialMapSetting.getOrAwaitValue() - val expected3 = PlaceListUiState.Error(exception) + val expected3 = ListLoadState.Error(exception) val actual3 = placeMapViewModel.placeGeographies.getOrAwaitValue() assertThat(actual2).isEqualTo(expected2) @@ -190,7 +190,7 @@ class PlaceMapViewModelTest { // then coVerify { placeDetailRepository.getPlaceDetail(1) } - val expected = PlaceUiState.Success(FAKE_PLACE_DETAIL.toUiModel()) + val expected = LoadState.Success(FAKE_PLACE_DETAIL.toUiModel()) val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -209,7 +209,7 @@ class PlaceMapViewModelTest { advanceUntilIdle() // then - val expected = PlaceUiState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) + val expected = LoadState.Success(FAKE_ETC_PLACE_DETAIL.toUiModel()) val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } @@ -230,7 +230,7 @@ class PlaceMapViewModelTest { advanceUntilIdle() // then - val expected = PlaceUiState.Empty + val expected = LoadState.Empty val actual = placeMapViewModel.selectedPlace.getOrAwaitValue() assertThat(actual).isEqualTo(expected) } From 55b25011f6f17f18235ab0415f90c0f29e16b21a Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 30 Dec 2025 21:37:39 +0900 Subject: [PATCH 08/22] =?UTF-8?q?refactor(PlaceMap):=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel`에서 통합 관리되던 단일 이벤트 채널을 UI 관련 이벤트와 지도 제어 이벤트로 분리하고, 비대해진 `PlaceMapFragment`의 로직을 전용 핸들러와 델리게이트로 위임하여 구조를 개선했습니다. - **이벤트 및 ViewModel 분리:** - 기존 `PlaceMapEvent`에서 지도 조작 관련 이벤트를 신규 인터페이스인 `MapControlEvent`(`InitMap`, `SetMarkerByTimeTag` 등)로 분리했습니다. - `PlaceMapViewModel`이 UI 사이드 이펙트를 위한 `placeMapUiEvent`와 지도 제어를 위한 `mapControlUiEvent` 두 개의 Flow를 노출하도록 수정했습니다. - **핸들러(Handler) 클래스 도입:** - `PlaceMapFragment` 내부의 방대한 `when` 분기문을 제거하고, 역할을 분리한 전용 핸들러 클래스를 구현했습니다. - **`MapControlEventHandler`**: `NaverMap` 및 `MapManager`를 직접 조작하는 지도 로직을 담당합니다. - **`PlaceMapEventHandler`**: 스낵바 표시, 화면 이동(Navigation), 이미지 프리로드 등 Android UI 관련 로직을 담당합니다. - **상태 관리 위임(Delegate) 적용:** - `NaverMap` 객체와 `MapManager`의 상태 관리를 위해 `MapDelegate`와 `MapManagerDelegate`를 새로 추가했습니다. - 기존 `MapState` 클래스를 삭제하고 `MapDelegate`로 대체하였으며, `PlaceMapScreen` 및 `NaverMapContent`가 이를 참조하도록 변경했습니다. --- .../presentation/placeMap/PlaceMapFragment.kt | 188 ++++-------------- .../placeMap/component/NaverMapContent.kt | 33 +-- .../placeMap/component/PlaceMapScreen.kt | 5 +- .../placeMap/viewmodel/MapControlEvent.kt | 34 ++++ .../viewmodel/MapControlEventHandler.kt | 121 +++++++++++ .../placeMap/viewmodel/MapDelegate.kt | 30 +++ .../placeMap/viewmodel/MapManagerDelegate.kt | 15 ++ .../placeMap/viewmodel/PlaceMapEvent.kt | 28 --- .../viewmodel/PlaceMapEventHandler.kt | 53 +++++ .../placeMap/viewmodel/PlaceMapViewModel.kt | 37 ++-- 10 files changed, 320 insertions(+), 224 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapDelegate.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt 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 df79803..9f488b6 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 @@ -6,11 +6,11 @@ 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.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.viewModels @@ -25,29 +25,22 @@ 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.domain.model.TimeTag 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.placeDetail.PlaceDetailActivity import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.component.MapState -import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue import com.daedan.festabook.presentation.placeMap.component.PlaceMapScreen import com.daedan.festabook.presentation.placeMap.component.rememberPlaceListBottomSheetState -import com.daedan.festabook.presentation.placeMap.logging.CurrentLocationChecked import com.daedan.festabook.presentation.placeMap.logging.PlaceFragmentEnter -import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick -import com.daedan.festabook.presentation.placeMap.mapManager.MapManager -import com.daedan.festabook.presentation.placeMap.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel +import com.daedan.festabook.presentation.placeMap.viewmodel.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.viewmodel.MapDelegate +import com.daedan.festabook.presentation.placeMap.viewmodel.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapEvent +import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapEventHandler import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.util.FusedLocationSource @@ -55,9 +48,7 @@ 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.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch @@ -96,137 +87,40 @@ class PlaceMapFragment( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val uiState by placeMapViewModel.uiState.collectAsStateWithLifecycle() - var mapManager by remember { mutableStateOf(null) } + val density = LocalDensity.current val bottomSheetState = rememberPlaceListBottomSheetState() - val mapState = remember { MapState() } - - ObserveAsEvents(flow = placeMapViewModel.uiEvent) { event -> - when (event) { - is PlaceMapEvent.InitMap -> { - val naverMap = mapState.await() - naverMap.addOnLocationChangeListener { - binding.logger.log( - CurrentLocationChecked( - baseLogData = binding.logger.getBaseLogData(), - ), - ) - } - naverMap.locationSource = locationSource - } - - is PlaceMapEvent.InitMapManager -> { - val naverMap = mapState.await() - if (mapManager == null) { - val graph = - createGraphFactory().create( - naverMap, - event.initialMapSetting, - placeMapViewModel, - getInitialPadding(requireContext()), - ) - mapManager = graph.mapManager - mapManager?.setupBackToInitialPosition { isExceededMaxLength -> - placeMapViewModel.onPlaceMapAction( - PlaceMapAction.ExceededMaxLength(isExceededMaxLength), - ) - } - } - } - - is PlaceMapEvent.PreloadImages -> { - preloadImages( - requireContext(), - event.places, - ) - } - - is PlaceMapEvent.BackToInitialPosition -> { - mapManager?.moveToPosition() - } - - is PlaceMapEvent.MenuItemReClicked -> { - mapManager?.moveToPosition() - if (!event.isPreviewVisible) return@ObserveAsEvents - placeMapViewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) - appGraph.defaultFirebaseLogger.log( - PlaceMapButtonReClick( - baseLogData = appGraph.defaultFirebaseLogger.getBaseLogData(), - ), - ) - } - - is PlaceMapEvent.StartPlaceDetail -> { - startPlaceDetailActivity(event.placeDetail.value) - } - - is PlaceMapEvent.ShowErrorSnackBar -> { - showErrorSnackBar(event.error.throwable) - } - - is PlaceMapEvent.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 PlaceMapEvent.FilterMapByCategory -> { - val selectedCategories = event.selectedCategories - if (selectedCategories.isEmpty()) { - mapManager?.clearFilter() - } else { - mapManager?.filterMarkersByCategories(selectedCategories) - } - } - - is PlaceMapEvent.MapViewDrag -> { - if (event.isPreviewVisible) return@ObserveAsEvents - bottomSheetState.update(PlaceListBottomSheetValue.COLLAPSED) - } - - is PlaceMapEvent.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" - } - binding.logger.log( - PlaceMarkerClick( - baseLogData = binding.logger.getBaseLogData(), - placeId = place.value.place.id, - timeTagName = timeTagName, - category = place.value.place.category.name, - ), - ) - } + 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, + ) + } + 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) }, + ) + } - else -> Unit - } - } + ObserveAsEvents(flow = placeMapViewModel.mapControlUiEvent) { event -> + mapControlEventHandler(event) + } - is PlaceMapEvent.UnselectMarker -> { - mapManager?.unselectMarker() - } - } + ObserveAsEvents(flow = placeMapViewModel.placeMapUiEvent) { event -> + placeMapEventHandler(event) } FestabookTheme { @@ -234,7 +128,7 @@ class PlaceMapFragment( uiState = uiState, onAction = { placeMapViewModel.onPlaceMapAction(it) }, bottomSheetState = bottomSheetState, - mapState = mapState, + mapDelegate = mapDelegate, ) } } @@ -263,11 +157,11 @@ class PlaceMapFragment( val defaultImage = ContextCompat .getDrawable( - requireContext(), + context, R.drawable.img_fallback, )?.asImage() - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch { places .take(maxSize) .filterNotNull() @@ -300,7 +194,5 @@ class PlaceMapFragment( 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/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index f3b1176..0a7683c 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 @@ -7,12 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -21,20 +18,15 @@ 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.viewmodel.MapDelegate import com.naver.maps.map.MapView import com.naver.maps.map.NaverMap -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds @Composable fun NaverMapContent( modifier: Modifier = Modifier, - mapState: MapState = MapState(), + mapDelegate: MapDelegate = MapDelegate(), onMapDrag: () -> Unit = {}, onMapReady: (NaverMap) -> Unit = {}, content: @Composable (NaverMap?) -> Unit, @@ -43,31 +35,14 @@ fun NaverMapContent( val mapView = remember { MapView(context) } LaunchedEffect(mapView) { val naverMap = mapView.getMapAndRunCallback(onMapReady) - mapState.initMap(naverMap) + mapDelegate.initMap(naverMap) } AndroidView( factory = { mapView }, modifier = modifier.dragInterceptor(onMapDrag), ) RegisterMapLifeCycle(mapView) - content(mapState.value) -} - -class MapState { - 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() - } + content(mapDelegate.value) } private fun Modifier.dragInterceptor(onMapDrag: () -> Unit): Modifier = 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 3bb5d13..da7a9e8 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 @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import com.daedan.festabook.presentation.placeMap.model.LoadState +import com.daedan.festabook.presentation.placeMap.viewmodel.MapDelegate import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapUiState import com.daedan.festabook.presentation.theme.FestabookColor @@ -22,12 +23,12 @@ fun PlaceMapScreen( uiState: PlaceMapUiState, onAction: (PlaceMapAction) -> Unit, bottomSheetState: PlaceListBottomSheetState, - mapState: MapState, + mapDelegate: MapDelegate, modifier: Modifier = Modifier, ) { NaverMapContent( modifier = modifier.fillMaxSize(), - mapState = mapState, + mapDelegate = mapDelegate, onMapReady = { onAction(PlaceMapAction.OnMapReady) }, onMapDrag = { onAction(PlaceMapAction.OnMapDrag) }, ) { naverMap -> diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt new file mode 100644 index 0000000..2019350 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt @@ -0,0 +1,34 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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.LoadState +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/viewmodel/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt new file mode 100644 index 0000000..0706d1b --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt @@ -0,0 +1,121 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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.logging.CurrentLocationChecked +import com.daedan.festabook.presentation.placeMap.logging.PlaceMarkerClick +import com.daedan.festabook.presentation.placeMap.mapManager.MapManager +import com.daedan.festabook.presentation.placeMap.model.LoadState +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, +) { + private val uiState = viewModel.uiState.value + private val mapManager: MapManager? get() = mapManagerDelegate.value + + 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( + PlaceMapAction.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/viewmodel/MapDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapDelegate.kt new file mode 100644 index 0000000..b8f25b7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapDelegate.kt @@ -0,0 +1,30 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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/viewmodel/MapManagerDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt new file mode 100644 index 0000000..d9ed7ea --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt @@ -0,0 +1,15 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +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/viewmodel/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt index 18eeea0..dfbfe3d 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt @@ -1,20 +1,10 @@ package com.daedan.festabook.presentation.placeMap.viewmodel -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.LoadState -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel sealed interface PlaceMapEvent { - data object InitMap : PlaceMapEvent - - data class InitMapManager( - val initialMapSetting: InitialMapSettingUiModel, - ) : PlaceMapEvent - data class StartPlaceDetail( val placeDetail: LoadState.Success, ) : PlaceMapEvent @@ -27,29 +17,11 @@ sealed interface PlaceMapEvent { val error: LoadState.Error, ) : PlaceMapEvent - data object BackToInitialPosition : PlaceMapEvent - data class MenuItemReClicked( val isPreviewVisible: Boolean, ) : PlaceMapEvent - data class SetMarkerByTimeTag( - val placeGeographies: List, - val selectedTimeTag: LoadState, - val isInitial: Boolean, - ) : PlaceMapEvent - - data class FilterMapByCategory( - val selectedCategories: List, - ) : PlaceMapEvent - data class MapViewDrag( val isPreviewVisible: Boolean, ) : PlaceMapEvent - - data class SelectMarker( - val placeDetail: LoadState, - ) : PlaceMapEvent - - data object UnselectMarker : PlaceMapEvent } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt new file mode 100644 index 0000000..afc4aa7 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt @@ -0,0 +1,53 @@ +package com.daedan.festabook.presentation.placeMap.viewmodel + +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetState +import com.daedan.festabook.presentation.placeMap.component.PlaceListBottomSheetValue +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, +) { + private val mapManager: MapManager? get() = mapManagerDelegate.value + + suspend operator fun invoke(event: PlaceMapEvent) { + when (event) { + is PlaceMapEvent.PreloadImages -> { + onPreloadImages(event) + } + + is PlaceMapEvent.MenuItemReClicked -> { + mapManager?.moveToPosition() + if (!event.isPreviewVisible) return + viewModel.onPlaceMapAction(PlaceMapAction.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/viewmodel/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt index 202f56a..9341c44 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt @@ -53,8 +53,11 @@ class PlaceMapViewModel( private val _uiState = MutableStateFlow(PlaceMapUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _uiEvent = Channel() - val uiEvent: Flow = _uiEvent.receiveAsFlow() + private val _placeMapUiEvent = Channel() + val placeMapUiEvent: Flow = _placeMapUiEvent.receiveAsFlow() + + private val _mapControlUiEvent = Channel() + val mapControlUiEvent: Flow = _mapControlUiEvent.receiveAsFlow() init { loadOrganizationGeography() @@ -67,10 +70,10 @@ class PlaceMapViewModel( viewModelScope.launch { when (action) { is PlaceMapAction.OnMapReady -> { - _uiEvent.send(PlaceMapEvent.InitMap) + _mapControlUiEvent.send(MapControlEvent.InitMap) val setting = uiState.await> { it.initialMapSetting } - _uiEvent.send(PlaceMapEvent.InitMapManager(setting.value)) + _mapControlUiEvent.send(MapControlEvent.InitMapManager(setting.value)) } is PlaceMapAction.OnTimeTagClick -> { @@ -108,7 +111,7 @@ class PlaceMapViewModel( } is PlaceMapAction.OnPlaceLoadFinish -> - _uiEvent.send( + _placeMapUiEvent.send( PlaceMapEvent.PreloadImages( action.places, ), @@ -120,7 +123,7 @@ class PlaceMapViewModel( baseLogData = logger.getBaseLogData(), ), ) - _uiEvent.send(PlaceMapEvent.BackToInitialPosition) + _mapControlUiEvent.send(MapControlEvent.BackToInitialPosition) } is PlaceMapAction.OnCategoryClick -> { @@ -132,7 +135,7 @@ class PlaceMapViewModel( it.copy(selectedCategories = action.categories) } - _uiEvent.send(PlaceMapEvent.FilterMapByCategory(action.categories.toList())) + _mapControlUiEvent.send(MapControlEvent.FilterMapByCategory(action.categories.toList())) logger.log( PlaceCategoryClick( @@ -143,7 +146,7 @@ class PlaceMapViewModel( } is PlaceMapAction.OnMapDrag -> { - _uiEvent.send( + _placeMapUiEvent.send( PlaceMapEvent.MapViewDrag( uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, ), @@ -160,7 +163,7 @@ class PlaceMapViewModel( if (selectedPlace is LoadState.Success && selectedTimeTag is LoadState.Success ) { - _uiEvent.send(PlaceMapEvent.StartPlaceDetail(action.place)) + _placeMapUiEvent.send(PlaceMapEvent.StartPlaceDetail(action.place)) logger.log( PlacePreviewClick( baseLogData = logger.getBaseLogData(), @@ -190,7 +193,7 @@ class PlaceMapViewModel( } fun onMenuItemReClicked() { - _uiEvent.trySend( + _placeMapUiEvent.trySend( PlaceMapEvent.MenuItemReClicked( uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, ), @@ -231,8 +234,8 @@ class PlaceMapViewModel( val placeGeographies = uiState.await>> { it.placeGeographies } - _uiEvent.send( - PlaceMapEvent.SetMarkerByTimeTag( + _mapControlUiEvent.send( + MapControlEvent.SetMarkerByTimeTag( placeGeographies = placeGeographies.value, selectedTimeTag = selectedTimeTag, isInitial = true, @@ -249,8 +252,8 @@ class PlaceMapViewModel( viewModelScope.launch { val placeGeographies = uiState.await>> { it.placeGeographies } - _uiEvent.send( - PlaceMapEvent.SetMarkerByTimeTag( + _mapControlUiEvent.send( + MapControlEvent.SetMarkerByTimeTag( placeGeographies = placeGeographies.value, selectedTimeTag = LoadState.Success(item), isInitial = false, @@ -268,7 +271,7 @@ class PlaceMapViewModel( _uiState.update { it.copy(selectedPlace = LoadState.Success(item.toUiModel())) } - _uiEvent.send(PlaceMapEvent.SelectMarker(uiState.value.selectedPlace)) + _mapControlUiEvent.send(MapControlEvent.SelectMarker(uiState.value.selectedPlace)) val selectedTimeTag = uiState.value.selectedTimeTag val timeTagName = if (selectedTimeTag is LoadState.Success) selectedTimeTag.value.name else "undefined" @@ -288,7 +291,7 @@ class PlaceMapViewModel( private fun unselectPlace() { _uiState.update { it.copy(selectedPlace = LoadState.Empty) } - _uiEvent.trySend(PlaceMapEvent.UnselectMarker) + _mapControlUiEvent.trySend(MapControlEvent.UnselectMarker) } private fun loadOrganizationGeography() { @@ -390,7 +393,7 @@ class PlaceMapViewModel( .filterIsInstance() .debounce(1000) .collect { - _uiEvent.send(PlaceMapEvent.ShowErrorSnackBar(it)) + _placeMapUiEvent.send(PlaceMapEvent.ShowErrorSnackBar(it)) } } } From 6a287bdaec7c6614b93cca964909f75476ad6a89 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 30 Dec 2025 21:37:59 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix(MapFilterManager):=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=ED=83=9C=EA=B7=B8=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EB=A7=88=EC=BB=A4=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타임태그가 선택되지 않은(`EMTPY_TIME_TAG_ID`) 상태에서 필터를 초기화할 때, 모든 마커가 숨겨지는 문제를 수정했습니다. - **`MapFilterManagerImpl.kt` 수정:** - `clearFilter()` 메서드에서 `selectedTimeTagId`가 비어있는 경우, 타임태그 일치 여부를 검사하지 않고 모든 마커를 보이도록(`isVisible = true`) 로직을 개선했습니다. --- .../placeMap/mapManager/internal/MapFilterManagerImpl.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From f3b5565e06ce4da05fe7dcc989a78ffe58334817 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 31 Dec 2025 11:56:10 +0900 Subject: [PATCH 10/22] =?UTF-8?q?refactor(PlaceMap):=20=EB=B9=84=EB=8C=80?= =?UTF-8?q?=ED=95=B4=EC=A7=84=20ViewModel=203=EA=B0=9C=EC=9D=98=20Handler?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비대해진 `PlaceMapViewModel`의 책임을 분산하기 위해 Action 처리 로직을 기능별 핸들러로 위임하고, 관련 패키지 구조를 `intent` 중심으로 재구성했습니다. - **Action 및 Handler 세분화:** - 단일 `PlaceMapAction`을 `SelectAction`(장소 선택 및 상호작용), `FilterAction`(필터링 및 데이터 로드), `MapEventAction`(지도 제어 이벤트)으로 분리했습니다. - 각 Action을 전담하여 처리하는 `SelectActionHandler`, `FilterActionHandler`, `MapEventActionHandler`를 도입하여 ViewModel의 로직을 분리했습니다. - **패키지 구조 재편:** - `viewmodel` 패키지에 혼재되어 있던 클래스들을 `intent` 패키지 하위의 `action`, `event`, `state`로 이동하여 구조를 명확히 했습니다. - `PlaceMapUiState`, `LoadState`, `ListLoadState` 등의 상태 클래스와 `MapControlEvent` 등의 이벤트 클래스가 해당 패키지로 이동되었습니다. - **UI 계층 수정:** - `PlaceMapScreen` 및 `PlaceMapFragment`에서 ViewModel로 이벤트를 전달할 때, 기존의 포괄적인 `PlaceMapAction` 대신 구체화된 Action(`SelectAction.OnPlaceClick` 등)을 사용하도록 호출부를 수정했습니다. --- .../di/mapManager/MapManagerBindings.kt | 2 +- .../di/mapManager/MapManagerGraph.kt | 2 +- .../presentation/placeMap/PlaceMapFragment.kt | 13 +- .../placeMap/PlaceMapViewModel.kt | 217 ++++++++++ .../placeMap/component/NaverMapContent.kt | 2 +- .../component/PlaceDetailPreviewScreen.kt | 2 +- .../PlaceDetailPreviewSecondaryScreen.kt | 2 +- .../placeMap/component/PlaceListScreen.kt | 2 +- .../placeMap/component/PlaceMapScreen.kt | 35 +- .../placeMap/component/TimeTagMenu.kt | 2 +- .../placeMap/intent/action/FilterAction.kt | 11 + .../intent/action/FilterActionHandler.kt | 131 ++++++ .../placeMap/intent/action/MapEventAction.kt | 15 + .../intent/action/MapEventActionHandler.kt | 54 +++ .../placeMap/intent/action/PlaceMapAction.kt | 3 + .../placeMap/intent/action/SelectAction.kt | 27 ++ .../intent/action/SelectActionHandler.kt | 137 ++++++ .../event}/MapControlEvent.kt | 4 +- .../event}/MapControlEventHandler.kt | 10 +- .../event}/PlaceMapEvent.kt | 4 +- .../event}/PlaceMapEventHandler.kt | 7 +- .../{model => intent/state}/ListLoadState.kt | 4 +- .../{model => intent/state}/LoadState.kt | 5 +- .../state}/MapDelegate.kt | 2 +- .../state}/MapManagerDelegate.kt | 2 +- .../state}/PlaceMapUiState.kt | 5 +- .../{viewmodel => intent/state}/StateExt.kt | 2 +- .../placeMap/listener/MapClickListenerImpl.kt | 8 +- .../placeMap/viewmodel/PlaceMapAction.kt | 45 -- .../viewmodel/PlaceMapActionHandler.kt | 3 - .../placeMap/viewmodel/PlaceMapViewModel.kt | 401 ------------------ .../placeList/PlaceListViewModelTest.kt | 2 +- .../placeList/PlaceMapViewModelTest.kt | 6 +- 33 files changed, 661 insertions(+), 506 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterAction.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventAction.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/PlaceMapAction.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectAction.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/event}/MapControlEvent.kt (88%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/event}/MapControlEventHandler.kt (89%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/event}/PlaceMapEvent.kt (83%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/event}/PlaceMapEventHandler.kt (84%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{model => intent/state}/ListLoadState.kt (72%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{model => intent/state}/LoadState.kt (66%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/state}/MapDelegate.kt (92%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/state}/MapManagerDelegate.kt (85%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/state}/PlaceMapUiState.kt (87%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/{viewmodel => intent/state}/StateExt.kt (87%) delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt delete mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt 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 eb8368a..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,11 +1,11 @@ package com.daedan.festabook.di.mapManager +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 -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.naver.maps.map.overlay.Marker import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo diff --git a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt index 4f522e0..88cfa05 100644 --- a/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/mapManager/MapManagerGraph.kt @@ -1,8 +1,8 @@ package com.daedan.festabook.di.mapManager +import com.daedan.festabook.presentation.placeMap.PlaceMapViewModel import com.daedan.festabook.presentation.placeMap.mapManager.MapManager import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.naver.maps.map.NaverMap import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.Provides 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 9f488b6..362a96b 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 @@ -34,14 +34,13 @@ 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.event.MapControlEventHandler +import com.daedan.festabook.presentation.placeMap.intent.event.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.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.viewmodel.MapControlEventHandler -import com.daedan.festabook.presentation.placeMap.viewmodel.MapDelegate -import com.daedan.festabook.presentation.placeMap.viewmodel.MapManagerDelegate -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapEventHandler -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.util.FusedLocationSource import dev.zacsweers.metro.AppScope @@ -136,7 +135,7 @@ class PlaceMapFragment( } override fun onMenuItemReClick() { - placeMapViewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) + placeMapViewModel.onPlaceMapAction(SelectAction.UnSelectPlace) placeMapViewModel.onMenuItemReClicked() } 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 new file mode 100644 index 0000000..9421c41 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -0,0 +1,217 @@ +package com.daedan.festabook.presentation.placeMap + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.viewmodel.ViewModelKey +import com.daedan.festabook.domain.repository.PlaceDetailRepository +import com.daedan.festabook.domain.repository.PlaceListRepository +import com.daedan.festabook.logging.DefaultFirebaseLogger +import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction +import com.daedan.festabook.presentation.placeMap.intent.action.FilterActionHandler +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventActionHandler +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.action.SelectActionHandler +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.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.StateFlow +import kotlinx.coroutines.flow.asStateFlow +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) +@ViewModelKey(PlaceMapViewModel::class) +@Inject +class PlaceMapViewModel( + private val placeListRepository: PlaceListRepository, + private val placeDetailRepository: PlaceDetailRepository, + private val logger: DefaultFirebaseLogger, +) : ViewModel() { + 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 mapEventActionHandler = + MapEventActionHandler( + _mapControlUiEvent = _mapControlUiEvent, + _placeMapUiEvent = _placeMapUiEvent, + uiState = uiState, + logger = logger, + ) + + private val filterActionHandler = + FilterActionHandler( + _mapControlUiEvent = _mapControlUiEvent, + logger = logger, + uiState = uiState, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + onUpdateState = { _uiState.update(it) }, + ) + + private val selectActionHandler = + SelectActionHandler( + filterActionHandler = filterActionHandler, + _placeMapUiEvent = _placeMapUiEvent, + _mapControlUiEvent = _mapControlUiEvent, + uiState = uiState, + logger = logger, + placeDetailRepository = placeDetailRepository, + scope = viewModelScope, + onUpdateState = { _uiState.update(it) }, + ) + + init { + loadOrganizationGeography() + loadTimeTags() + loadAllPlaces() + observeErrorEvent() + } + + fun onPlaceMapAction(action: PlaceMapAction) { + viewModelScope.launch { + when (action) { + is FilterAction -> filterActionHandler(action) + is MapEventAction -> mapEventActionHandler(action) + is SelectAction -> selectActionHandler(action) + } + } + } + + fun onMenuItemReClicked() { + _placeMapUiEvent.trySend( + PlaceMapEvent.MenuItemReClicked( + uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, + ), + ) + } + + private fun loadTimeTags() { + viewModelScope.launch { + placeListRepository + .getTimeTags() + .onSuccess { timeTags -> + _uiState.update { + it.copy( + timeTags = LoadState.Success(timeTags), + ) + } + }.onFailure { + _uiState.update { + it.copy( + timeTags = LoadState.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) + } + + val placeGeographies = + uiState.await>> { it.placeGeographies } + _mapControlUiEvent.send( + MapControlEvent.SetMarkerByTimeTag( + placeGeographies = placeGeographies.value, + selectedTimeTag = selectedTimeTag, + isInitial = true, + ), + ) + } + } + + private fun loadOrganizationGeography() { + viewModelScope.launch { + placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> + _uiState.update { + it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) + } + } + + launch { + placeListRepository + .getPlaceGeographies() + .onSuccess { placeGeographies -> + _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/component/NaverMapContent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/NaverMapContent.kt index 0a7683c..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 @@ -18,7 +18,7 @@ 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.viewmodel.MapDelegate +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 diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index 32e23e7..f8b4e73 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -22,7 +22,7 @@ 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.model.LoadState +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.theme.FestabookColor diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index 90be06a..3750617 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -18,7 +18,7 @@ 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.model.LoadState +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.getIconId diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index 3d4a174..beeaeda 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -39,7 +39,7 @@ 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.model.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel import com.daedan.festabook.presentation.theme.FestabookTheme 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 da7a9e8..b7c31cd 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 @@ -11,10 +11,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp -import com.daedan.festabook.presentation.placeMap.model.LoadState -import com.daedan.festabook.presentation.placeMap.viewmodel.MapDelegate -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapUiState +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.festabookSpacing @@ -29,8 +32,8 @@ fun PlaceMapScreen( NaverMapContent( modifier = modifier.fillMaxSize(), mapDelegate = mapDelegate, - onMapReady = { onAction(PlaceMapAction.OnMapReady) }, - onMapDrag = { onAction(PlaceMapAction.OnMapDrag) }, + onMapReady = { onAction(MapEventAction.OnMapReady) }, + onMapDrag = { onAction(MapEventAction.OnMapDrag) }, ) { naverMap -> Column( modifier = Modifier.wrapContentSize(), @@ -39,7 +42,7 @@ fun PlaceMapScreen( timeTagsState = uiState.timeTags, selectedTimeTagState = uiState.selectedTimeTag, onTimeTagClick = { timeTag -> - onAction(PlaceMapAction.OnTimeTagClick(timeTag)) + onAction(SelectAction.OnTimeTagClick(timeTag)) }, modifier = Modifier @@ -50,8 +53,8 @@ fun PlaceMapScreen( PlaceCategoryScreen( initialCategories = uiState.initialCategories, selectedCategories = uiState.selectedCategories, - onCategoryClick = { onAction(PlaceMapAction.OnCategoryClick(it)) }, - onDisplayAllClick = { onAction(PlaceMapAction.OnCategoryClick(it)) }, + onCategoryClick = { onAction(FilterAction.OnCategoryClick(it)) }, + onDisplayAllClick = { onAction(FilterAction.OnCategoryClick(it)) }, ) Box( @@ -75,12 +78,12 @@ fun PlaceMapScreen( ), placesUiState = uiState.places, map = naverMap, - onPlaceClick = { onAction(PlaceMapAction.OnPlaceClick(it.id)) }, + onPlaceClick = { onAction(SelectAction.OnPlaceClick(it.id)) }, bottomSheetState = bottomSheetState, isExceededMaxLength = uiState.isExceededMaxLength, - onPlaceLoadFinish = { onAction(PlaceMapAction.OnPlaceLoadFinish(it)) }, - onPlaceLoad = { onAction(PlaceMapAction.OnPlaceLoad) }, - onBackToInitialPositionClick = { onAction(PlaceMapAction.OnBackToInitialPositionClick) }, + onPlaceLoadFinish = { onAction(MapEventAction.OnPlaceLoadFinish(it)) }, + onPlaceLoad = { onAction(FilterAction.OnPlaceLoad) }, + onBackToInitialPositionClick = { onAction(MapEventAction.OnBackToInitialPositionClick) }, ) if (uiState.isPlacePreviewVisible) { @@ -94,8 +97,8 @@ fun PlaceMapScreen( ), selectedPlace = uiState.selectedPlace, visible = true, - onClick = { onAction(PlaceMapAction.OnPlacePreviewClick(it)) }, - onBackPress = { onAction(PlaceMapAction.OnBackPress) }, + onClick = { onAction(SelectAction.OnPlacePreviewClick(it)) }, + onBackPress = { onAction(SelectAction.OnBackPress) }, ) } @@ -110,7 +113,7 @@ fun PlaceMapScreen( ), selectedPlace = uiState.selectedPlace, visible = true, - onBackPress = { onAction(PlaceMapAction.OnBackPress) }, + onBackPress = { onAction(SelectAction.OnBackPress) }, ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index 0608c6e..370a17b 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -41,7 +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.model.LoadState +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 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/FilterActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt new file mode 100644 index 0000000..edbc2fe --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt @@ -0,0 +1,131 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +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.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 kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class FilterActionHandler( + private val _mapControlUiEvent: Channel, + private val logger: DefaultFirebaseLogger, + private val uiState: StateFlow, + private val cachedPlaces: StateFlow>, + private val cachedPlaceByTimeTag: StateFlow>, + private val onUpdateCachedPlace: (List) -> Unit, + private val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, +) { + 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/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/MapEventActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt new file mode 100644 index 0000000..0802472 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt @@ -0,0 +1,54 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +import com.daedan.festabook.logging.DefaultFirebaseLogger +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 kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow + +class MapEventActionHandler( + private val _mapControlUiEvent: Channel, + private val _placeMapUiEvent: Channel, + private val uiState: StateFlow, + private val logger: DefaultFirebaseLogger, +) { + 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/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/action/SelectActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt new file mode 100644 index 0000000..a8b6787 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt @@ -0,0 +1,137 @@ +package com.daedan.festabook.presentation.placeMap.intent.action + +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.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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class SelectActionHandler( + private val filterActionHandler: FilterActionHandler, + private val _placeMapUiEvent: Channel, + private val _mapControlUiEvent: Channel, + private val uiState: StateFlow, + private val logger: DefaultFirebaseLogger, + private val placeDetailRepository: PlaceDetailRepository, + private val scope: CoroutineScope, + private val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, +) { + 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 -> + onUpdateState.invoke { + it.copy(selectedPlace = LoadState.Success(item.toUiModel())) + } + _mapControlUiEvent.send(MapControlEvent.SelectMarker(uiState.value.selectedPlace)) + 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/viewmodel/MapControlEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt similarity index 88% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt index 2019350..12c5faf 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEvent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEvent.kt @@ -1,9 +1,9 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +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.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt similarity index 89% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt index 0706d1b..3f89ee5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapControlEventHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt @@ -1,12 +1,16 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.event 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.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.daedan.festabook.presentation.placeMap.model.LoadState import com.naver.maps.map.LocationSource import dev.zacsweers.metro.createGraphFactory @@ -48,7 +52,7 @@ class MapControlEventHandler( mapManagerDelegate.init(graph.mapManager) mapManager?.setupBackToInitialPosition { isExceededMaxLength -> viewModel.onPlaceMapAction( - PlaceMapAction.ExceededMaxLength(isExceededMaxLength), + SelectAction.ExceededMaxLength(isExceededMaxLength), ) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt similarity index 83% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt index dfbfe3d..27580a5 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEvent.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEvent.kt @@ -1,7 +1,7 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.event import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.model.LoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel sealed interface PlaceMapEvent { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt similarity index 84% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt index afc4aa7..d4dd029 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapEventHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt @@ -1,8 +1,11 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.event 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.state.MapManagerDelegate import com.daedan.festabook.presentation.placeMap.logging.PlaceMapButtonReClick import com.daedan.festabook.presentation.placeMap.mapManager.MapManager @@ -28,7 +31,7 @@ class PlaceMapEventHandler( is PlaceMapEvent.MenuItemReClicked -> { mapManager?.moveToPosition() if (!event.isPreviewVisible) return - viewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) + viewModel.onPlaceMapAction(SelectAction.UnSelectPlace) logger.log( PlaceMapButtonReClick( baseLogData = logger.getBaseLogData(), diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/ListLoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt similarity index 72% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/model/ListLoadState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt index 1bad571..54cedfb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/ListLoadState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/ListLoadState.kt @@ -1,4 +1,6 @@ -package com.daedan.festabook.presentation.placeMap.model +package com.daedan.festabook.presentation.placeMap.intent.state + +import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel sealed interface ListLoadState { class Loading : ListLoadState diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt similarity index 66% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt index a0f9162..a29a908 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/model/LoadState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/LoadState.kt @@ -1,6 +1,7 @@ -package com.daedan.festabook.presentation.placeMap.model +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 @@ -16,4 +17,4 @@ sealed interface LoadState { ) : LoadState } -val LoadState.Success.isSecondary get() = value.place.category in PlaceCategoryUiModel.SECONDARY_CATEGORIES +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/viewmodel/MapDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt similarity index 92% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapDelegate.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt index b8f25b7..051f4b9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapDelegate.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapDelegate.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.state import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt similarity index 85% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt index d9ed7ea..947ee52 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/MapManagerDelegate.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/MapManagerDelegate.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.state import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt index 2840ba4..e75f009 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapUiState.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/PlaceMapUiState.kt @@ -1,14 +1,11 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +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.ListLoadState -import com.daedan.festabook.presentation.placeMap.model.LoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -import com.daedan.festabook.presentation.placeMap.model.isSecondary data class PlaceMapUiState( val initialMapSetting: LoadState = LoadState.Loading, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt similarity index 87% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt index 8da0e7d..42f12b1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/StateExt.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/state/StateExt.kt @@ -1,4 +1,4 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel +package com.daedan.festabook.presentation.placeMap.intent.state import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt index 75d9253..fb07386 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/listener/MapClickListenerImpl.kt @@ -1,8 +1,8 @@ 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 com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapAction -import com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import timber.log.Timber class MapClickListenerImpl( @@ -14,13 +14,13 @@ class MapClickListenerImpl( ): Boolean { Timber.d("Marker CLick : placeID: $placeId categoty: $category") viewModel.onPlaceMapAction( - PlaceMapAction.OnPlaceClick(placeId), + SelectAction.OnPlaceClick(placeId), ) return true } override fun onMapClickListener() { Timber.d("Map CLick") - viewModel.onPlaceMapAction(PlaceMapAction.UnSelectPlace) + viewModel.onPlaceMapAction(SelectAction.UnSelectPlace) } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt deleted file mode 100644 index 405c087..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapAction.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel - -import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.presentation.placeDetail.model.PlaceDetailUiModel -import com.daedan.festabook.presentation.placeMap.model.LoadState -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel - -sealed interface PlaceMapAction { - data object OnMapReady : PlaceMapAction - - data class OnTimeTagClick( - val timeTag: TimeTag, - ) : PlaceMapAction - - data object OnMapDrag : PlaceMapAction - - data class OnPlaceClick( - val placeId: Long, - ) : PlaceMapAction - - data class OnPlacePreviewClick( - val place: LoadState, - ) : PlaceMapAction - - data object OnBackPress : PlaceMapAction - - data class OnPlaceLoadFinish( - val places: List, - ) : PlaceMapAction - - data object OnBackToInitialPositionClick : PlaceMapAction - - data class OnCategoryClick( - val categories: Set, - ) : PlaceMapAction - - data object OnPlaceLoad : PlaceMapAction - - data class ExceededMaxLength( - val isExceededMaxLength: Boolean, - ) : PlaceMapAction - - data object UnSelectPlace : PlaceMapAction -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt deleted file mode 100644 index 7d309cd..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapActionHandler.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel - -class PlaceMapActionHandler diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt deleted file mode 100644 index 9341c44..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/viewmodel/PlaceMapViewModel.kt +++ /dev/null @@ -1,401 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.viewmodel - -import androidx.lifecycle.ViewModel -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.PlaceDetailRepository -import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.logging.DefaultFirebaseLogger -import com.daedan.festabook.presentation.placeDetail.model.toUiModel -import com.daedan.festabook.presentation.placeMap.logging.PlaceBackToSchoolClick -import com.daedan.festabook.presentation.placeMap.logging.PlaceCategoryClick -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.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.ListLoadState -import com.daedan.festabook.presentation.placeMap.model.LoadState -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -import com.daedan.festabook.presentation.placeMap.model.PlaceCoordinateUiModel -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.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -@ContributesIntoMap(AppScope::class) -@ViewModelKey(PlaceMapViewModel::class) -@Inject -class PlaceMapViewModel( - private val placeListRepository: PlaceListRepository, - private val placeDetailRepository: PlaceDetailRepository, - private val logger: DefaultFirebaseLogger, -) : ViewModel() { - private var cachedPlaces = listOf() - private var cachedPlaceByTimeTag: List = 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() - - init { - loadOrganizationGeography() - loadTimeTags() - loadAllPlaces() - waitEvent() - } - - fun onPlaceMapAction(action: PlaceMapAction) { - viewModelScope.launch { - when (action) { - is PlaceMapAction.OnMapReady -> { - _mapControlUiEvent.send(MapControlEvent.InitMap) - val setting = - uiState.await> { it.initialMapSetting } - _mapControlUiEvent.send(MapControlEvent.InitMapManager(setting.value)) - } - - is PlaceMapAction.OnTimeTagClick -> { - onDaySelected(action.timeTag) - logger.log( - PlaceTimeTagSelected( - baseLogData = logger.getBaseLogData(), - timeTagName = action.timeTag.name, - ), - ) - } - - is PlaceMapAction.OnPlaceClick -> { - selectPlace(action.placeId) - } - - is PlaceMapAction.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 - } - } - - is PlaceMapAction.OnPlaceLoadFinish -> - _placeMapUiEvent.send( - PlaceMapEvent.PreloadImages( - action.places, - ), - ) - - is PlaceMapAction.OnBackToInitialPositionClick -> { - logger.log( - PlaceBackToSchoolClick( - baseLogData = logger.getBaseLogData(), - ), - ) - _mapControlUiEvent.send(MapControlEvent.BackToInitialPosition) - } - - is PlaceMapAction.OnCategoryClick -> { - uiState.await> { it.places } - unselectPlace() - updatePlacesByCategories(action.categories.toList()) - - _uiState.update { - 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 PlaceMapAction.OnMapDrag -> { - _placeMapUiEvent.send( - PlaceMapEvent.MapViewDrag( - uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, - ), - ) - } - - is PlaceMapAction.OnBackPress -> { - unselectPlace() - } - - is PlaceMapAction.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 PlaceMapAction.ExceededMaxLength -> { - _uiState.update { - it.copy( - isExceededMaxLength = action.isExceededMaxLength, - ) - } - } - - is PlaceMapAction.UnSelectPlace -> { - unselectPlace() - } - } - } - } - - fun onMenuItemReClicked() { - _placeMapUiEvent.trySend( - PlaceMapEvent.MenuItemReClicked( - uiState.value.isPlacePreviewVisible || uiState.value.isPlaceSecondaryPreviewVisible, - ), - ) - } - - private fun loadTimeTags() { - viewModelScope.launch { - placeListRepository - .getTimeTags() - .onSuccess { timeTags -> - _uiState.update { - it.copy( - timeTags = LoadState.Success(timeTags), - ) - } - }.onFailure { - _uiState.update { - it.copy( - timeTags = LoadState.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) - } - - val placeGeographies = - uiState.await>> { it.placeGeographies } - _mapControlUiEvent.send( - MapControlEvent.SetMarkerByTimeTag( - placeGeographies = placeGeographies.value, - selectedTimeTag = selectedTimeTag, - isInitial = true, - ), - ) - } - } - - private fun onDaySelected(item: TimeTag) { - unselectPlace() - _uiState.update { - it.copy(selectedTimeTag = LoadState.Success(item)) - } - viewModelScope.launch { - val placeGeographies = - uiState.await>> { it.placeGeographies } - _mapControlUiEvent.send( - MapControlEvent.SetMarkerByTimeTag( - placeGeographies = placeGeographies.value, - selectedTimeTag = LoadState.Success(item), - isInitial = false, - ), - ) - } - } - - private fun selectPlace(placeId: Long) { - viewModelScope.launch { - _uiState.update { it.copy(selectedPlace = LoadState.Loading) } - placeDetailRepository - .getPlaceDetail(placeId = placeId) - .onSuccess { item -> - _uiState.update { - it.copy(selectedPlace = LoadState.Success(item.toUiModel())) - } - _mapControlUiEvent.send(MapControlEvent.SelectMarker(uiState.value.selectedPlace)) - 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 -> - _uiState.update { it.copy(selectedPlace = LoadState.Error(item)) } - } - } - } - - private fun unselectPlace() { - _uiState.update { it.copy(selectedPlace = LoadState.Empty) } - _mapControlUiEvent.trySend(MapControlEvent.UnselectMarker) - } - - private fun loadOrganizationGeography() { - viewModelScope.launch { - placeListRepository.getOrganizationGeography().onSuccess { organizationGeography -> - _uiState.update { - it.copy(initialMapSetting = LoadState.Success(organizationGeography.toUiModel())) - } - } - - launch { - placeListRepository - .getPlaceGeographies() - .onSuccess { placeGeographies -> - _uiState.update { - it.copy( - placeGeographies = LoadState.Success(placeGeographies.map { it.toUiModel() }), - ) - } - }.onFailure { item -> - _uiState.update { - it.copy(placeGeographies = LoadState.Error(item)) - } - } - } - } - } - - 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 - .filter { place -> - place.category in category - } - _uiState.update { it.copy(places = ListLoadState.Success(filteredPlaces)) } - } - - private fun filterPlacesByTimeTag(timeTagId: Long): List { - val filteredPlaces = - cachedPlaces.filter { place -> - place.timeTagId.contains(timeTagId) - } - return filteredPlaces - } - - private fun updatePlacesByTimeTag(timeTagId: Long) { - val filteredPlaces = - if (timeTagId == TimeTag.EMTPY_TIME_TAG_ID) { - cachedPlaces - } else { - filterPlacesByTimeTag(timeTagId) - } - - _uiState.update { it.copy(places = ListLoadState.Success(filteredPlaces)) } - cachedPlaceByTimeTag = filteredPlaces - } - - private fun clearPlacesFilter() { - _uiState.update { it.copy(places = ListLoadState.Success(cachedPlaceByTimeTag)) } - } - - private fun loadAllPlaces() { - viewModelScope.launch { - val result = placeListRepository.getPlaces() - result - .onSuccess { places -> - val placeUiModels = places.map { it.toUiModel() } - cachedPlaces = placeUiModels - _uiState.update { it.copy(places = ListLoadState.PlaceLoaded(placeUiModels)) } - }.onFailure { error -> - _uiState.update { it.copy(places = ListLoadState.Error(error)) } - } - } - } - - @OptIn(FlowPreview::class) - private fun waitEvent() { - viewModelScope.launch { - launch { - uiState - .map { it.hasAnyError } - .distinctUntilChanged() - .filterIsInstance() - .debounce(1000) - .collect { - _placeMapUiEvent.send(PlaceMapEvent.ShowErrorSnackBar(it)) - } - } - } - } -} diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt index 0d69d00..f1e681d 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt @@ -4,7 +4,7 @@ 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.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.ListLoadState import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel import com.daedan.festabook.presentation.placeMap.model.toUiModel import io.mockk.coEvery diff --git a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt index 936bf8a..19bf0ad 100644 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt @@ -9,13 +9,13 @@ 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.intent.state.ListLoadState +import com.daedan.festabook.presentation.placeMap.intent.state.LoadState import com.daedan.festabook.presentation.placeMap.model.InitialMapSettingUiModel -import com.daedan.festabook.presentation.placeMap.model.ListLoadState -import com.daedan.festabook.presentation.placeMap.model.LoadState 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 com.daedan.festabook.presentation.placeMap.viewmodel.PlaceMapViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk From f67125b1e88fad1691047c29e6e920c200807fe2 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 31 Dec 2025 16:57:30 +0900 Subject: [PATCH 11/22] =?UTF-8?q?refactor(PlaceMap):=20ActionHandler=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(Metro)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel` 내부에서 수동으로 생성하던 ActionHandler 객체들을 Metro 프레임워크를 사용한 의존성 주입 방식으로 변경했습니다. 이를 위해 별도의 핸들러 그래프를 정의하고, 뷰모델의 런타임 의존성을 그래프를 통해 주입받도록 구조를 개선했습니다. - **`PlaceMapViewModel.kt` 수정:** - `MapEventActionHandler`, `FilterActionHandler`, `SelectActionHandler`를 직접 인스턴스화하던 코드를 제거했습니다. - `PlaceMapHandlerGraph.Factory`를 이용해 `handlerGraph`를 생성하고, 필요한 `Channel`, `StateFlow`, Scope 등을 주입했습니다. - `onPlaceMapAction`에서 개별 핸들러 변수 대신 `handlerGraph`를 통해 핸들러를 호출하도록 변경했습니다. - **ActionHandler 클래스 수정:** - `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`에 `@Inject` 어노테이션을 추가했습니다. - `FilterActionHandler`의 생성자 파라미터에 `@CachedPlaces`, `@CachedPlaceByTimeTag` 한정자(Qualifier)를 적용하여 의존성을 명확히 했습니다. - **DI 구성요소 추가 (`di/placeMapHandler`):** - `PlaceMapHandlerGraph`: 핸들러 인스턴스를 제공하고, 런타임 의존성을 주입받는 팩토리 인터페이스를 정의했습니다. - `CachedPlaces`, `CachedPlaceByTimeTag`: 동일한 타입의 `StateFlow` 의존성을 구분하기 위한 Qualifier 어노테이션을 추가했습니다. --- .../placeMapHandler/CachedPlaceByTimeTag.kt | 6 ++ .../di/placeMapHandler/CachedPlaces.kt | 6 ++ .../placeMapHandler/PlaceMapHandlerGraph.kt | 38 ++++++++++++ .../placeMapHandler/PlaceMapViewModelScope.kt | 3 + .../placeMap/PlaceMapViewModel.kt | 60 +++++++------------ .../intent/action/FilterActionHandler.kt | 8 ++- .../intent/action/MapEventActionHandler.kt | 2 + .../intent/action/SelectActionHandler.kt | 2 + 8 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaceByTimeTag.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/CachedPlaces.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt create mode 100644 app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapViewModelScope.kt 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..a206cb4 --- /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.action.FilterActionHandler +import com.daedan.festabook.presentation.placeMap.intent.action.MapEventActionHandler +import com.daedan.festabook.presentation.placeMap.intent.action.SelectActionHandler +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.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/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 9421c41..e47eff0 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 @@ -2,17 +2,14 @@ package com.daedan.festabook.presentation.placeMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.daedan.festabook.di.FestaBookAppGraph +import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph import com.daedan.festabook.di.viewmodel.ViewModelKey -import com.daedan.festabook.domain.repository.PlaceDetailRepository import com.daedan.festabook.domain.repository.PlaceListRepository -import com.daedan.festabook.logging.DefaultFirebaseLogger import com.daedan.festabook.presentation.placeMap.intent.action.FilterAction -import com.daedan.festabook.presentation.placeMap.intent.action.FilterActionHandler import com.daedan.festabook.presentation.placeMap.intent.action.MapEventAction -import com.daedan.festabook.presentation.placeMap.intent.action.MapEventActionHandler 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.action.SelectActionHandler 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 @@ -25,6 +22,7 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.asContribution import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -44,8 +42,7 @@ import kotlinx.coroutines.launch @Inject class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, - private val placeDetailRepository: PlaceDetailRepository, - private val logger: DefaultFirebaseLogger, + appGraph: FestaBookAppGraph, ) : ViewModel() { private val cachedPlaces = MutableStateFlow(listOf()) private val cachedPlaceByTimeTag = MutableStateFlow>(emptyList()) @@ -59,36 +56,19 @@ class PlaceMapViewModel( private val _mapControlUiEvent = Channel() val mapControlUiEvent: Flow = _mapControlUiEvent.receiveAsFlow() - private val mapEventActionHandler = - MapEventActionHandler( - _mapControlUiEvent = _mapControlUiEvent, - _placeMapUiEvent = _placeMapUiEvent, - uiState = uiState, - logger = logger, - ) - - private val filterActionHandler = - FilterActionHandler( - _mapControlUiEvent = _mapControlUiEvent, - logger = logger, - uiState = uiState, - cachedPlaces = cachedPlaces, - cachedPlaceByTimeTag = cachedPlaceByTimeTag, - onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, - onUpdateState = { _uiState.update(it) }, - ) - - private val selectActionHandler = - SelectActionHandler( - filterActionHandler = filterActionHandler, - _placeMapUiEvent = _placeMapUiEvent, - _mapControlUiEvent = _mapControlUiEvent, - uiState = uiState, - logger = logger, - placeDetailRepository = placeDetailRepository, - scope = viewModelScope, - onUpdateState = { _uiState.update(it) }, - ) + private val handlerGraph = + appGraph + .asContribution() + .create( + mapControlUiEvent = _mapControlUiEvent, + placeMapUiEvent = _placeMapUiEvent, + uiState = uiState, + cachedPlaces = cachedPlaces, + cachedPlaceByTimeTag = cachedPlaceByTimeTag, + onUpdateCachedPlace = { cachedPlaceByTimeTag.tryEmit(it) }, + onUpdateState = { _uiState.update(it) }, + scope = viewModelScope, + ) init { loadOrganizationGeography() @@ -100,9 +80,9 @@ class PlaceMapViewModel( fun onPlaceMapAction(action: PlaceMapAction) { viewModelScope.launch { when (action) { - is FilterAction -> filterActionHandler(action) - is MapEventAction -> mapEventActionHandler(action) - is SelectAction -> selectActionHandler(action) + is FilterAction -> handlerGraph.filterActionHandler(action) + is MapEventAction -> handlerGraph.mapEventActionHandler(action) + is SelectAction -> handlerGraph.selectActionHandler(action) } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt index edbc2fe..d5f5638 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt @@ -1,5 +1,7 @@ package com.daedan.festabook.presentation.placeMap.intent.action +import com.daedan.festabook.di.placeMapHandler.CachedPlaceByTimeTag +import com.daedan.festabook.di.placeMapHandler.CachedPlaces import com.daedan.festabook.domain.model.PlaceCategory import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.logging.DefaultFirebaseLogger @@ -12,18 +14,20 @@ 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.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 class FilterActionHandler( private val _mapControlUiEvent: Channel, private val logger: DefaultFirebaseLogger, private val uiState: StateFlow, - private val cachedPlaces: StateFlow>, - private val cachedPlaceByTimeTag: StateFlow>, + @param:CachedPlaces private val cachedPlaces: StateFlow>, + @param:CachedPlaceByTimeTag private val cachedPlaceByTimeTag: StateFlow>, private val onUpdateCachedPlace: (List) -> Unit, private val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, ) { diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt index 0802472..5b14e74 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt @@ -8,9 +8,11 @@ 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.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.StateFlow +@Inject class MapEventActionHandler( private val _mapControlUiEvent: Channel, private val _placeMapUiEvent: Channel, diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt index a8b6787..cfc270c 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt @@ -13,11 +13,13 @@ 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.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +@Inject class SelectActionHandler( private val filterActionHandler: FilterActionHandler, private val _placeMapUiEvent: Channel, From f2d693fd81b2778e1279c5904916ad4b708150a3 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 1 Jan 2026 19:37:56 +0900 Subject: [PATCH 12/22] =?UTF-8?q?refactor(PlaceMap):=20Intent=20Handler=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=91=9C=EC=A4=80=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMap` 화면의 사용자 액션(Action)과 이벤트(Event)를 처리하는 핸들러 클래스들을 `intent.handler` 패키지로 통합하고, 공통 인터페이스를 도입하여 구조를 표준화했습니다. - **인터페이스 정의:** - `ActionHandler` 및 `EventHandler` 인터페이스를 새로 정의하여 핸들러의 구현 규약을 통일했습니다. - **패키지 이동 및 구현 수정:** - 기존 `intent.action`과 `intent.event` 패키지에 있던 `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`, `PlaceMapEventHandler`, `MapControlEventHandler`를 `intent.handler` 패키지로 이동했습니다. - 각 핸들러 클래스가 `ActionHandler` 또는 `EventHandler` 인터페이스를 구현(`override`)하도록 로직을 수정했습니다. - **의존성 주입(DI) 설정:** - `SelectActionHandler`, `MapEventActionHandler`, `FilterActionHandler`에 `@ContributesBinding(PlaceMapViewModelScope::class)`을 추가하여 의존성 주입 설정을 보강했습니다. --- .../di/placeMapHandler/PlaceMapHandlerGraph.kt | 6 +++--- .../presentation/placeMap/PlaceMapFragment.kt | 4 ++-- .../placeMap/intent/handler/ActionHandler.kt | 10 ++++++++++ .../placeMap/intent/handler/EventHandler.kt | 5 +++++ .../{action => handler}/FilterActionHandler.kt | 16 ++++++++++------ .../{event => handler}/MapControlEventHandler.kt | 7 ++++--- .../{action => handler}/MapEventActionHandler.kt | 13 +++++++++---- .../{event => handler}/PlaceMapEventHandler.kt | 7 ++++--- .../{action => handler}/SelectActionHandler.kt | 14 +++++++++----- 9 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/ActionHandler.kt create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/EventHandler.kt rename app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/{action => handler}/FilterActionHandler.kt (89%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/{event => handler}/MapControlEventHandler.kt (95%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/{action => handler}/MapEventActionHandler.kt (78%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/{event => handler}/PlaceMapEventHandler.kt (90%) rename app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/{action => handler}/SelectActionHandler.kt (90%) 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 index a206cb4..caf5ba4 100644 --- a/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt +++ b/app/src/main/java/com/daedan/festabook/di/placeMapHandler/PlaceMapHandlerGraph.kt @@ -1,10 +1,10 @@ package com.daedan.festabook.di.placeMapHandler -import com.daedan.festabook.presentation.placeMap.intent.action.FilterActionHandler -import com.daedan.festabook.presentation.placeMap.intent.action.MapEventActionHandler -import com.daedan.festabook.presentation.placeMap.intent.action.SelectActionHandler 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 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 362a96b..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 @@ -35,8 +35,8 @@ 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.event.MapControlEventHandler -import com.daedan.festabook.presentation.placeMap.intent.event.PlaceMapEventHandler +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 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/action/FilterActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt similarity index 89% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt index d5f5638..fd7e592 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/FilterActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/FilterActionHandler.kt @@ -1,10 +1,12 @@ -package com.daedan.festabook.presentation.placeMap.intent.action +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 @@ -14,6 +16,7 @@ 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 @@ -22,16 +25,17 @@ 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 uiState: StateFlow, + private val onUpdateCachedPlace: (List) -> Unit, @param:CachedPlaces private val cachedPlaces: StateFlow>, @param:CachedPlaceByTimeTag private val cachedPlaceByTimeTag: StateFlow>, - private val onUpdateCachedPlace: (List) -> Unit, - private val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, -) { - suspend operator fun invoke(action: FilterAction) { +) : ActionHandler { + override suspend operator fun invoke(action: FilterAction) { when (action) { is FilterAction.OnCategoryClick -> { uiState.await> { it.places } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt similarity index 95% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt index 3f89ee5..719d33f 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/MapControlEventHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapControlEventHandler.kt @@ -1,10 +1,11 @@ -package com.daedan.festabook.presentation.placeMap.intent.event +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 @@ -21,11 +22,11 @@ class MapControlEventHandler( private val viewModel: PlaceMapViewModel, private val mapDelegate: MapDelegate, private val mapManagerDelegate: MapManagerDelegate, -) { +) : EventHandler { private val uiState = viewModel.uiState.value private val mapManager: MapManager? get() = mapManagerDelegate.value - suspend operator fun invoke(event: MapControlEvent) { + override suspend operator fun invoke(event: MapControlEvent) { when (event) { is MapControlEvent.InitMap -> { val naverMap = mapDelegate.await() diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt similarity index 78% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt index 5b14e74..d49ecb0 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/MapEventActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/MapEventActionHandler.kt @@ -1,6 +1,8 @@ -package com.daedan.festabook.presentation.placeMap.intent.action +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 @@ -8,18 +10,21 @@ 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 uiState: StateFlow, private val logger: DefaultFirebaseLogger, -) { - suspend operator fun invoke(action: MapEventAction) { +) : ActionHandler { + override suspend operator fun invoke(action: MapEventAction) { when (action) { is MapEventAction.OnMapReady -> { _mapControlUiEvent.send(MapControlEvent.InitMap) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt similarity index 90% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt index d4dd029..d465339 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/event/PlaceMapEventHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/PlaceMapEventHandler.kt @@ -1,10 +1,11 @@ -package com.daedan.festabook.presentation.placeMap.intent.event +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 @@ -19,10 +20,10 @@ class PlaceMapEventHandler( 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 - suspend operator fun invoke(event: PlaceMapEvent) { + override suspend operator fun invoke(event: PlaceMapEvent) { when (event) { is PlaceMapEvent.PreloadImages -> { onPreloadImages(event) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt similarity index 90% rename from app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt rename to app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt index cfc270c..d529c64 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/action/SelectActionHandler.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/intent/handler/SelectActionHandler.kt @@ -1,9 +1,11 @@ -package com.daedan.festabook.presentation.placeMap.intent.action +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 @@ -13,6 +15,7 @@ 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 @@ -20,17 +23,18 @@ 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 uiState: StateFlow, private val logger: DefaultFirebaseLogger, private val placeDetailRepository: PlaceDetailRepository, private val scope: CoroutineScope, - private val onUpdateState: ((PlaceMapUiState) -> PlaceMapUiState) -> Unit, -) { - suspend operator fun invoke(action: SelectAction) { +) : ActionHandler { + override suspend operator fun invoke(action: SelectAction) { when (action) { is SelectAction.OnPlaceClick -> { selectPlace(action.placeId) From d5913f4c107079ab8e6056c51aabb424f9d2f3aa Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 1 Jan 2026 19:57:20 +0900 Subject: [PATCH 13/22] =?UTF-8?q?refactor(PlaceMap):=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=EB=90=9C=20UI=20=EC=88=98=EC=B9=98=EB=A5=BC?= =?UTF-8?q?=20Theme=20Spacing=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도(PlaceMap) 기능 내 컴포넌트들에 산재되어 있던 하드코딩된 치수(dp)와 비율 값들을 `FestabookSpacing`의 확장 프로퍼티로 정의하고 적용하여 코드의 유지보수성을 높였습니다. - **`Styles.kt` 추가:** - `FestabookSpacing`의 확장 프로퍼티를 담은 `Styles.kt`를 생성했습니다. - `previewVerticalPadding`, `placeListImageSize`, `placeListBottomSheetPeekHeight` 등 특정 컴포넌트에서 사용되는 UI 상수를 한곳에 정의했습니다. - **`PlaceListScreen.kt` 수정:** - `PlaceListBottomSheet`의 `peekHeight`와 `halfExpandedRatio`, 그리고 리스트 아이템의 이미지 크기를 새로 정의한 상수로 대체했습니다. - **`PlaceDetailPreviewScreen.kt` 수정:** - 화면의 수직 패딩과 장소 이미지 크기에 하드코딩된 값 대신 `festabookSpacing` 확장 프로퍼티를 적용했습니다. - **기타 컴포넌트 수정:** - `PlaceCategoryLabel.kt`: 텍스트 패딩 값을 `festabookSpacing.paddingBody1`으로 변경했습니다. - `TimeTagMenu.kt`: 메뉴의 너비 값을 `timeTagButtonWidth` 상수로 변경했습니다. --- .../placeMap/component/PlaceCategoryLabel.kt | 3 ++- .../component/PlaceDetailPreviewScreen.kt | 5 ++-- .../placeMap/component/PlaceListScreen.kt | 6 ++--- .../placeMap/component/PlaceMapScreen.kt | 3 +-- .../presentation/placeMap/component/Styles.kt | 25 +++++++++++++++++++ .../placeMap/component/TimeTagMenu.kt | 2 +- 6 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/daedan/festabook/presentation/placeMap/component/Styles.kt 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/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index f8b4e73..afc11eb 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -16,7 +16,6 @@ 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 @@ -71,7 +70,7 @@ private fun PlaceDetailPreviewContent( modifier = modifier.padding( horizontal = festabookSpacing.paddingScreenGutter, - vertical = 20.dp, + vertical = festabookSpacing.previewVerticalPadding, ), ) { PlaceCategoryLabel( @@ -149,7 +148,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), diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index beeaeda..cdd5ba2 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -97,8 +97,8 @@ fun PlaceListScreen( } PlaceListBottomSheet( - peekHeight = 70.dp, - halfExpandedRatio = 0.4f, + peekHeight = festabookSpacing.placeListBottomSheetPeekHeight, + halfExpandedRatio = festabookSpacing.placeListBottomSheetHalfRatio, onStateUpdate = { if (listState.firstVisibleItemIndex != 0) { scope.launch { listState.scrollToItem(0) } @@ -206,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( 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 b7c31cd..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 @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.dp 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 @@ -48,7 +47,7 @@ fun PlaceMapScreen( Modifier .background( FestabookColor.white, - ).padding(horizontal = 24.dp), + ).padding(horizontal = festabookSpacing.timeTagHorizontalPadding), ) PlaceCategoryScreen( initialCategories = uiState.initialCategories, 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/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt index 370a17b..5b535f8 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/TimeTagMenu.kt @@ -148,7 +148,7 @@ private fun ExposedDropdownMenuBoxScope.TimeTagButton( Row( modifier = Modifier - .width(140.dp) + .width(festabookSpacing.timeTagButtonWidth) .onGloballyPositioned { coordinates -> onSizeDetermine(coordinates.size) }.menuAnchor( From f71dc7bf8f854d8447a47e90409edcc3ab3c0132 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 16:17:32 +0900 Subject: [PATCH 14/22] =?UTF-8?q?test(PlaceMap):=20ActionHandler=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel`의 비대해진 로직을 분리하기 위해 ActionHandler 패턴을 도입하고, 상태 관리 방식을 `StateFlow`로 전면 개편했습니다. - **`PlaceMapViewModel.kt` 리팩토링:** - `PlaceMapHandlerGraph.Factory`를 주입받아 로직을 `SelectActionHandler`, `FilterActionHandler`, `MapEventActionHandler`로 위임하도록 구조를 변경했습니다. - 기존 `LiveData` 기반의 상태 관리를 `StateFlow`(`uiState`)와 `Channel`(`mapControlUiEvent`, `placeMapUiEvent`)로 전환했습니다. - **ActionHandler 테스트 코드 추가:** - `SelectActionHandlerTest`: 플레이스 선택/해제, 상세 정보 조회, 타임태그 클릭 등의 로직 테스트를 작성했습니다. - `FilterActionHandlerTest`: 카테고리 및 타임태그 기반의 장소 필터링 로직 테스트를 작성했습니다. - `MapEventActionHandlerTest`: 지도 초기화, 초기 위치 복귀, 드래그 이벤트 처리 로직 테스트를 작성했습니다. - **테스트 및 유틸리티 정비:** - `PlaceMapViewModelTest`: 핸들러 위임 동작 및 `StateFlow` 상태 변화를 검증하도록 테스트를 수정했습니다. - `PlaceListViewModelTest`를 삭제하고, 관련 픽스처 및 테스트 파일들을 `placeList`에서 `placeMap` 패키지로 이동했습니다. - Flow 기반 테스트를 지원하기 위한 `observeEvent` 확장 함수(`FlowExtensions.kt`)를 추가했습니다. --- .../placeMap/PlaceMapViewModel.kt | 18 +- .../com/daedan/festabook/FlowExtensions.kt | 19 + .../placeDetail/PlaceDetailTestFixture.kt | 2 +- .../placeDetail/PlaceDetailViewModelTest.kt | 2 +- .../placeList/PlaceListViewModelTest.kt | 161 --------- .../placeList/PlaceMapViewModelTest.kt | 329 ------------------ .../PlaceLIstTestFixture.kt | 59 +++- .../placeMap/PlaceMapViewModelTest.kt | 253 ++++++++++++++ .../handler/FilterActionHandlerTest.kt | 229 ++++++++++++ .../handler/MapEventActionHandlerTest.kt | 156 +++++++++ .../handler/SelectActionHandlerTest.kt | 231 ++++++++++++ 11 files changed, 959 insertions(+), 500 deletions(-) create mode 100644 app/src/test/java/com/daedan/festabook/FlowExtensions.kt delete mode 100644 app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt delete mode 100644 app/src/test/java/com/daedan/festabook/placeList/PlaceMapViewModelTest.kt rename app/src/test/java/com/daedan/festabook/{placeList => placeMap}/PlaceLIstTestFixture.kt (65%) create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt create mode 100644 app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt 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 e47eff0..d800466 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 @@ -2,7 +2,6 @@ package com.daedan.festabook.presentation.placeMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.daedan.festabook.di.FestaBookAppGraph import com.daedan.festabook.di.placeMapHandler.PlaceMapHandlerGraph import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.PlaceListRepository @@ -22,8 +21,8 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.asContribution import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -42,7 +41,7 @@ import kotlinx.coroutines.launch @Inject class PlaceMapViewModel( private val placeListRepository: PlaceListRepository, - appGraph: FestaBookAppGraph, + handlerGraphFactory: PlaceMapHandlerGraph.Factory, ) : ViewModel() { private val cachedPlaces = MutableStateFlow(listOf()) private val cachedPlaceByTimeTag = MutableStateFlow>(emptyList()) @@ -50,15 +49,20 @@ class PlaceMapViewModel( private val _uiState = MutableStateFlow(PlaceMapUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _placeMapUiEvent = Channel() + private val _placeMapUiEvent = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) val placeMapUiEvent: Flow = _placeMapUiEvent.receiveAsFlow() - private val _mapControlUiEvent = Channel() + private val _mapControlUiEvent = + Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) val mapControlUiEvent: Flow = _mapControlUiEvent.receiveAsFlow() private val handlerGraph = - appGraph - .asContribution() + handlerGraphFactory .create( mapControlUiEvent = _mapControlUiEvent, placeMapUiEvent = _placeMapUiEvent, 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..f277d8f --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt @@ -0,0 +1,19 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle + +@OptIn(ExperimentalCoroutinesApi::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + flow.first() + } + advanceUntilIdle() + return event +} 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 f1e681d..0000000 --- a/app/src/test/java/com/daedan/festabook/placeList/PlaceListViewModelTest.kt +++ /dev/null @@ -1,161 +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.intent.state.ListLoadState -import com.daedan.festabook.presentation.placeMap.model.PlaceCategoryUiModel -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 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(ListLoadState.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(ListLoadState.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(ListLoadState.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(ListLoadState.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(ListLoadState.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(ListLoadState.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 19bf0ad..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.intent.state.ListLoadState -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.PlaceUiModel -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(ListLoadState.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(ListLoadState.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 = - ListLoadState.Success(FAKE_ORGANIZATION_GEOGRAPHY.toUiModel()) - val actual2 = placeMapViewModel.initialMapSetting.getOrAwaitValue() - - val expected3 = ListLoadState.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 = LoadState.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 = LoadState.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 = LoadState.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/PlaceLIstTestFixture.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/PlaceLIstTestFixture.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/PlaceLIstTestFixture.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..197a406 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt @@ -0,0 +1,253 @@ +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) + } +} 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..2f76542 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt @@ -0,0 +1,229 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.observeEvent +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 event = observeEvent(mapControlUiEvent.consumeAsFlow()) + val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) + uiState.update { it.copy(places = places) } + + // when + filterActionHandler(FilterAction.OnCategoryClick(categories)) + + // then + val eventResult = event.await() + advanceUntilIdle() + + assertThat( + uiState.value.selectedCategories, + ).isEqualTo( + categories, + ) + + assertThat(eventResult).isInstanceOf(MapControlEvent.FilterMapByCategory::class.java) + 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..137f983 --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt @@ -0,0 +1,156 @@ +package com.daedan.festabook.placeMap.handler + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.daedan.festabook.observeEvent +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.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +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), + ) + } + + @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() + backgroundScope.launch(UnconfinedTestDispatcher()) { + mapControlUiEvent.receiveAsFlow().collect { + eventResult.add(it) + } + } + + 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..4657eaa --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt @@ -0,0 +1,231 @@ +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.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +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, + ) + } + + @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) + } +} From 14bfbb2d86cd84705743bc17dbc76611d2b951be Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 21:33:39 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix(FestaBookApp):=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주석 처리되어 있던 `setGlobalExceptionHandler()` 메서드 호출을 복구하여, 앱 실행 중 발생하는 예외를 전역적으로 처리하는 로직을 다시 활성화했습니다. - **`FestaBookApp.kt` 수정:** - `onCreate` 메서드 내에서 주석으로 막혀있던 `setGlobalExceptionHandler()` 호출부의 주석을 해제했습니다. --- app/src/main/java/com/daedan/festabook/FestaBookApp.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt index 9047076..8675a95 100644 --- a/app/src/main/java/com/daedan/festabook/FestaBookApp.kt +++ b/app/src/main/java/com/daedan/festabook/FestaBookApp.kt @@ -36,7 +36,7 @@ class FestaBookApp : Application() { override fun onCreate() { super.onCreate() -// setGlobalExceptionHandler() + setGlobalExceptionHandler() festaBookGraph.inject(this) setupTimber() setupNaverSdk() From f98fbefe98cd3dd662717ea6c9a95a1b379fa368 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 21:47:47 +0900 Subject: [PATCH 16/22] =?UTF-8?q?refactor(PlaceMap):=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EC=A0=80=EB=B8=94=20=EC=82=AC=EC=9D=B4=EB=93=9C=20=EC=9D=B4?= =?UTF-8?q?=ED=8E=99=ED=8A=B8=20when=EC=A0=88=20->=20=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI 컴포지션 단계에서 발생하던 부수 효과(Side Effect)를 `LaunchedEffect`로 이동시켜 안정성을 높이고, 불필요한 에러 및 빈 상태 콜백을 제거하여 코드를 간소화했습니다. - **`PlaceListScreen.kt` 리팩토링:** - UI 렌더링 로직(`when`) 내에서 직접 호출되던 `onPlaceLoadFinish`와 `onPlaceLoad` 콜백을 `LaunchedEffect` 블록으로 이동했습니다. 이를 통해 상태 변화(`placesUiState`)에 따라 부수 효과가 안전하게 실행되도록 개선했습니다. - 사용하지 않는 `onError` 파라미터와 관련 호출 코드를 삭제했습니다. - **장소 상세 미리보기 화면 수정 (`PlaceDetailPreviewScreen`, `PlaceDetailPreviewSecondaryScreen`):** - `LoadState.Error` 및 `LoadState.Empty` 상태를 처리하기 위해 존재했던 `onError`, `onEmpty` 콜백 파라미터를 제거했습니다. - `Success` 상태가 아닐 경우 별도의 처리 없이 빈 화면을 유지하도록 `when` 분기문을 간소화했습니다. --- .../component/PlaceDetailPreviewScreen.kt | 5 +---- .../PlaceDetailPreviewSecondaryScreen.kt | 7 ++----- .../placeMap/component/PlaceListScreen.kt | 20 +++++++++---------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt index afc11eb..caff647 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewScreen.kt @@ -36,7 +36,6 @@ fun PlaceDetailPreviewScreen( modifier: Modifier = Modifier, visible: Boolean = false, onClick: (LoadState) -> Unit = {}, - onError: (LoadState.Error) -> Unit = {}, onBackPress: () -> Unit = {}, ) { BackHandler(enabled = visible) { @@ -50,13 +49,11 @@ fun PlaceDetailPreviewScreen( .clickable { onClick(selectedPlace) }, ) { when (selectedPlace) { - is LoadState.Loading -> Unit is LoadState.Success -> { PlaceDetailPreviewContent(placeDetail = selectedPlace.value) } - is LoadState.Error -> onError(selectedPlace) - is LoadState.Empty -> Unit + else -> Unit } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt index 3750617..2666046 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceDetailPreviewSecondaryScreen.kt @@ -32,8 +32,6 @@ import com.daedan.festabook.presentation.theme.festabookSpacing fun PlaceDetailPreviewSecondaryScreen( selectedPlace: LoadState, modifier: Modifier = Modifier, - onError: (LoadState.Error) -> Unit = {}, - onEmpty: () -> Unit = {}, onClick: (LoadState) -> Unit = {}, onBackPress: () -> Unit = {}, visible: Boolean = false, @@ -52,9 +50,6 @@ fun PlaceDetailPreviewSecondaryScreen( shape = festabookShapes.radius2, ) { when (selectedPlace) { - is LoadState.Loading -> Unit - is LoadState.Error -> onError(selectedPlace) - is LoadState.Empty -> onEmpty() is LoadState.Success -> { Row( modifier = @@ -87,6 +82,8 @@ fun PlaceDetailPreviewSecondaryScreen( ) } } + + else -> Unit } } } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt index cdd5ba2..6b32fb1 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/component/PlaceListScreen.kt @@ -61,13 +61,21 @@ fun PlaceListScreen( onPlaceClick: (place: PlaceUiModel) -> Unit = {}, onPlaceLoadFinish: (places: List) -> Unit = {}, onPlaceLoad: suspend () -> Unit = {}, - onError: (ListLoadState.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()) { if (bottomSheetState.currentValue != PlaceListBottomSheetValue.EXPANDED) { @@ -126,14 +134,12 @@ fun PlaceListScreen( ) is ListLoadState.Error -> { - onError(placesUiState) EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), ) } is ListLoadState.Success -> { - onPlaceLoadFinish(placesUiState.value) if (placesUiState.value.isEmpty()) { EmptyStateScreen( modifier = Modifier.offset(y = HALF_EXPANDED_OFFSET), @@ -148,13 +154,7 @@ fun PlaceListScreen( } } - is ListLoadState.PlaceLoaded -> { - LaunchedEffect(Unit) { - scope.launch { - currentOnPlaceLoad() - } - } - } + is ListLoadState.PlaceLoaded -> Unit } } } From 0942544c367ee225f309b37ad85439ea79fd2d9b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 21:51:08 +0900 Subject: [PATCH 17/22] =?UTF-8?q?refactor(PlaceMap):=20MapControlEventHand?= =?UTF-8?q?ler=20uiState=EC=97=90=20getter=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../placeMap/intent/handler/MapControlEventHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 719d33f..b5c276a 100644 --- 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 @@ -23,7 +23,7 @@ class MapControlEventHandler( private val mapDelegate: MapDelegate, private val mapManagerDelegate: MapManagerDelegate, ) : EventHandler { - private val uiState = viewModel.uiState.value + private val uiState get() = viewModel.uiState.value private val mapManager: MapManager? get() = mapManagerDelegate.value override suspend operator fun invoke(event: MapControlEvent) { From d2ddeeb0954f5f24831e53a1ce7573688ace58ed Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 22:35:37 +0900 Subject: [PATCH 18/22] =?UTF-8?q?test(PlaceMap):=20Flow=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow 기반의 단위 테스트 편의성을 위해 유틸리티 함수를 개선하고, `PlaceMapViewModel`의 데이터 로딩 실패 시나리오에 대한 테스트 케이스를 추가했습니다. - **`FlowExtensions.kt` 수정:** - `observeEvent`에 타임아웃(`3.seconds`)을 적용하여 테스트가 무한 대기하는 현상을 방지했습니다. - Flow에서 발생하는 여러 개의 이벤트를 리스트로 수집하여 검증할 수 있는 `observeMultipleEvent` 확장 함수를 추가했습니다. - **`PlaceMapViewModelTest.kt` 테스트 추가:** - 장소 데이터(`getPlaces`)나 지리 정보(`getPlaceGeographies`) 로딩 실패 시, `PlaceMapEvent.ShowErrorSnackBar` 이벤트가 정상적으로 발행되는지 확인하는 테스트 케이스 2종을 추가했습니다. - **핸들러 테스트 리팩토링:** - `FilterActionHandlerTest.kt` 및 `MapEventActionHandlerTest.kt`에서 수동으로 코루틴을 실행하여 이벤트를 수집하던 로직을 `observeMultipleEvent`로 대체하여 가독성을 높였습니다. - `FilterActionHandlerTest`에서 필터 카테고리 클릭 시 `UnselectMarker` 이벤트와 `FilterMapByCategory` 이벤트가 순서대로 발생하는지 `containsExactly`를 통해 명확히 검증하도록 수정했습니다. --- .../com/daedan/festabook/FlowExtensions.kt | 25 +++++++++- .../placeMap/PlaceMapViewModelTest.kt | 46 +++++++++++++++++++ .../handler/FilterActionHandlerTest.kt | 11 +++-- .../handler/MapEventActionHandlerTest.kt | 9 +--- 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt index f277d8f..53e8ac6 100644 --- a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt +++ b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt @@ -2,18 +2,39 @@ 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 kotlin.time.Duration.Companion.seconds -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) fun TestScope.observeEvent(flow: Flow): Deferred { val event = backgroundScope.async { - flow.first() + flow + .timeout(3.seconds) + .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/placeMap/PlaceMapViewModelTest.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt index 197a406..fd5b432 100644 --- a/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapViewModelTest.kt @@ -250,4 +250,50 @@ class PlaceMapViewModelTest { // 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 index 2f76542..832580b 100644 --- a/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/FilterActionHandlerTest.kt @@ -2,7 +2,7 @@ package com.daedan.festabook.placeMap.handler import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.model.TimeTag -import com.daedan.festabook.observeEvent +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 @@ -81,7 +81,8 @@ class FilterActionHandlerTest { runTest { // given val categories = setOf(PlaceCategoryUiModel.BOOTH) - val event = observeEvent(mapControlUiEvent.consumeAsFlow()) + val eventResult = mutableListOf() + observeMultipleEvent(mapControlUiEvent.consumeAsFlow(), eventResult) val places = ListLoadState.Success(FAKE_PLACES_CATEGORY_FIXTURE.map { it.toUiModel() }) uiState.update { it.copy(places = places) } @@ -89,7 +90,6 @@ class FilterActionHandlerTest { filterActionHandler(FilterAction.OnCategoryClick(categories)) // then - val eventResult = event.await() advanceUntilIdle() assertThat( @@ -98,7 +98,10 @@ class FilterActionHandlerTest { categories, ) - assertThat(eventResult).isInstanceOf(MapControlEvent.FilterMapByCategory::class.java) + assertThat(eventResult).containsExactly( + MapControlEvent.UnselectMarker, + MapControlEvent.FilterMapByCategory(categories.toList()), + ) assertThat(uiState.value.places).isEqualTo( ListLoadState.Success(emptyList()), ) 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 index 137f983..fc40c59 100644 --- a/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt @@ -2,6 +2,7 @@ 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 @@ -17,9 +18,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -83,11 +82,7 @@ class MapEventActionHandlerTest { runTest { // given val eventResult = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher()) { - mapControlUiEvent.receiveAsFlow().collect { - eventResult.add(it) - } - } + observeMultipleEvent(mapControlUiEvent.receiveAsFlow(), eventResult) val initialSetting = FAKE_INITIAL_MAP_SETTING uiState.update { From 64ceeb759b7bd03f8c8dd5df4440ec29b0ee7fc7 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 22:41:00 +0900 Subject: [PATCH 19/22] =?UTF-8?q?refactor(PlaceMap):=20ListLoadState=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EC=84=A0=ED=83=9D=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EC=8B=9C=20=EC=9D=BC=EA=B4=80=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ListLoadState`의 제네릭 타입을 공변적으로 변경하고, `Loading` 및 `Error` 상태를 단순화하여 상태 관리 구조를 개선했습니다. 또한, 장소 선택 시 마커 이벤트에 전달되는 데이터의 일관성을 확보했습니다. - **`ListLoadState.kt` 리팩토링:** - `ListLoadState` 인터페이스를 공변(`out T`)으로 변경했습니다. - `Loading`을 `class`에서 `data object`로 변경하여 싱글톤으로 관리되도록 하고, 불필요한 제네릭 타입을 `Nothing`으로 처리했습니다. - `Error` 상태 또한 제네릭 타입 `T`를 제거하고 `ListLoadState`을 구현하도록 수정했습니다. - **`PlaceMapUiState.kt` 수정:** - `places` 필드의 초기값을 변경된 구조에 맞춰 `ListLoadState.Loading()` 생성자 호출에서 `ListLoadState.Loading` 객체 참조로 수정했습니다. - **`SelectActionHandler.kt` 수정:** - 장소 상세 정보 로드 성공 시, 새로운 `LoadState.Success` 객체를 변수에 할당하여 상태 업데이트와 `MapControlEvent.SelectMarker` 이벤트 전송에 동일한 객체를 사용하도록 변경했습니다. 이를 통해 `uiState.value` 참조 시점 차이로 인한 데이터 불일치 문제를 방지했습니다. --- .../placeMap/intent/handler/SelectActionHandler.kt | 6 ++++-- .../presentation/placeMap/intent/state/ListLoadState.kt | 8 ++++---- .../presentation/placeMap/intent/state/PlaceMapUiState.kt | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) 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 index d529c64..406cfeb 100644 --- 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 @@ -95,10 +95,12 @@ class SelectActionHandler( placeDetailRepository .getPlaceDetail(placeId = placeId) .onSuccess { item -> + val newSelectedPlace = LoadState.Success(item.toUiModel()) + onUpdateState.invoke { - it.copy(selectedPlace = LoadState.Success(item.toUiModel())) + it.copy(selectedPlace = newSelectedPlace) } - _mapControlUiEvent.send(MapControlEvent.SelectMarker(uiState.value.selectedPlace)) + _mapControlUiEvent.send(MapControlEvent.SelectMarker(newSelectedPlace)) val selectedTimeTag = uiState.value.selectedTimeTag val timeTagName = if (selectedTimeTag is LoadState.Success) selectedTimeTag.value.name else "undefined" 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 index 54cedfb..1c305b1 100644 --- 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 @@ -2,8 +2,8 @@ package com.daedan.festabook.presentation.placeMap.intent.state import com.daedan.festabook.presentation.placeMap.model.PlaceUiModel -sealed interface ListLoadState { - class Loading : ListLoadState +sealed interface ListLoadState { + data object Loading : ListLoadState data class Success( val value: T, @@ -13,7 +13,7 @@ sealed interface ListLoadState { val value: List, ) : ListLoadState> - data class Error( + data class Error( val throwable: Throwable, - ) : ListLoadState + ) : ListLoadState } 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 index e75f009..95b9d65 100644 --- 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 @@ -13,7 +13,7 @@ data class PlaceMapUiState( val timeTags: LoadState> = LoadState.Empty, val selectedTimeTag: LoadState = LoadState.Empty, val selectedPlace: LoadState = LoadState.Empty, - val places: ListLoadState> = ListLoadState.Loading(), + val places: ListLoadState> = ListLoadState.Loading, val isExceededMaxLength: Boolean = false, val selectedCategories: Set = emptySet(), val initialCategories: List = PlaceCategoryUiModel.entries, From 946a669d9b124138a0351736b4bfd3305dfea442 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 23:13:24 +0900 Subject: [PATCH 20/22] =?UTF-8?q?refactor(PlaceMap):=20await=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=ED=95=A8=EC=88=98=EC=97=90=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapUiState`의 특정 상태 변화를 기다리는 `await` 확장 함수에 타임아웃 로직을 도입하여, 상태가 변경되지 않을 경우 무한 대기하는 문제를 방지하도록 개선했습니다. - **`StateExt.kt` 수정:** - `StateFlow.await` 함수에 `timeout` 파라미터(기본값 3초)를 추가했습니다. - 내부 로직을 `withTimeout` 블록으로 감싸, 지정된 시간 내에 조건에 맞는 상태가 수집되지 않으면 타임아웃이 발생하도록 변경했습니다. --- .../placeMap/intent/state/StateExt.kt | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index 42f12b1..74cdbf2 100644 --- 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 @@ -1,14 +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 -suspend inline fun StateFlow.await(crossinline selector: (PlaceMapUiState) -> Any?): R = - this - .map { selector(it) } - .distinctUntilChanged() - .filterIsInstance() - .first() +@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 + } From f4dd3b36eda5ae9778e45107f8c77393ece4901b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 23:20:02 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix(PlaceMap):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B1=84=EB=84=90=20=EC=84=A4=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=ED=95=B4=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceMapViewModel`의 이벤트 채널 생성 로직을 수정하고, 단위 테스트에서 코루틴 디스패처가 올바르게 해제되도록 개선했습니다. 또한 테스트 픽스처의 파일명을 적절하게 변경했습니다. - **`PlaceMapViewModel.kt` 수정:** - `_placeMapUiEvent`와 `_mapControlUiEvent` 채널 생성 시 설정되어 있던 `onBufferOverflow = BufferOverflow.DROP_OLDEST` 옵션을 제거하여 기본 채널 동작을 따르도록 변경했습니다. - **테스트 코드 개선:** - `SelectActionHandlerTest.kt` 및 `MapEventActionHandlerTest.kt`에 `@After` 어노테이션이 달린 `tearDown` 함수를 추가했습니다. - 각 테스트 종료 시 `Dispatchers.resetMain()`을 호출하여 메인 디스패처 설정을 초기화하도록 수정했습니다. - `PlaceLIstTestFixture.kt`의 파일명을 `PlaceMapTestFixture.kt`로 변경하여 오타를 수정하고 맥락에 맞게 네이밍을 개선했습니다. --- .../presentation/placeMap/PlaceMapViewModel.kt | 11 ++--------- ...PlaceLIstTestFixture.kt => PlaceMapTestFixture.kt} | 0 .../placeMap/handler/MapEventActionHandlerTest.kt | 7 +++++++ .../placeMap/handler/SelectActionHandlerTest.kt | 7 +++++++ 4 files changed, 16 insertions(+), 9 deletions(-) rename app/src/test/java/com/daedan/festabook/placeMap/{PlaceLIstTestFixture.kt => PlaceMapTestFixture.kt} (100%) 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 d800466..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 @@ -22,7 +22,6 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -49,16 +48,10 @@ class PlaceMapViewModel( private val _uiState = MutableStateFlow(PlaceMapUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _placeMapUiEvent = - Channel( - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + private val _placeMapUiEvent = Channel() val placeMapUiEvent: Flow = _placeMapUiEvent.receiveAsFlow() - private val _mapControlUiEvent = - Channel( - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + private val _mapControlUiEvent = Channel() val mapControlUiEvent: Flow = _mapControlUiEvent.receiveAsFlow() private val handlerGraph = diff --git a/app/src/test/java/com/daedan/festabook/placeMap/PlaceLIstTestFixture.kt b/app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt similarity index 100% rename from app/src/test/java/com/daedan/festabook/placeMap/PlaceLIstTestFixture.kt rename to app/src/test/java/com/daedan/festabook/placeMap/PlaceMapTestFixture.kt 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 index fc40c59..0147ac8 100644 --- a/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/MapEventActionHandlerTest.kt @@ -20,9 +20,11 @@ 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 @@ -60,6 +62,11 @@ class MapEventActionHandlerTest { ) } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `초기 위치로 돌아가기 버튼 클릭 시 이벤트가 방출된다`() = runTest { 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 index 4657eaa..6670cc7 100644 --- a/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt +++ b/app/src/test/java/com/daedan/festabook/placeMap/handler/SelectActionHandlerTest.kt @@ -27,9 +27,11 @@ 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 @@ -74,6 +76,11 @@ class SelectActionHandlerTest { ) } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `플레이스의 아이디와 카테고리가 있으면 플레이스 상세를 선택할 수 있다`() = runTest { From f915ae885f66bf34db92af53f0e6bb3b9c623301 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 2 Jan 2026 23:37:00 +0900 Subject: [PATCH 22/22] =?UTF-8?q?refactor(test):=20observeEvent=20timeout?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 유틸리티인 `observeEvent` 함수에서 Flow의 데이터를 수집할 때 사용하던 타임아웃 처리 방식을 변경했습니다. - **`FlowExtensions.kt` 수정:** - `flow.timeout(3.seconds).first()` 연산자 체인 대신, `withTimeout(3000)` 블록 내부에서 `flow.first()`를 호출하도록 로직을 수정했습니다. --- app/src/test/java/com/daedan/festabook/FlowExtensions.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt index 53e8ac6..16f9d7b 100644 --- a/app/src/test/java/com/daedan/festabook/FlowExtensions.kt +++ b/app/src/test/java/com/daedan/festabook/FlowExtensions.kt @@ -11,15 +11,16 @@ 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 { - flow - .timeout(3.seconds) - .first() + withTimeout(3000) { + flow.first() + } } advanceUntilIdle() return event