From d625fde59cf5913f060f9d74a3312dec19966cd5 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 29 Jun 2026 22:11:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20KakaoMapView=20Compose=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AndroidView로 MapView 호스팅, start()/MapReadyCallback 캡슐화, factory 1회 생성 - 생명주기(resume/pause/finish) DisposableEffect + LifecycleEventObserver 브리지 - onMapReady/onMapError 콜백을 상위 Composable로 노출 --- .../matching/map/compose/KakaoMapView.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt new file mode 100644 index 00000000..b01c95cc --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt @@ -0,0 +1,88 @@ +package com.project200.feature.matching.map.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.kakao.vectormap.KakaoMap +import com.kakao.vectormap.KakaoMapReadyCallback +import com.kakao.vectormap.MapLifeCycleCallback +import com.kakao.vectormap.MapView +import timber.log.Timber + +/** + * KakaoMap v2(MapView) 를 Compose 에서 사용하기 위한 재사용 Composable + * + * MapView 를 AndroidView 로 호스팅하고, + * 생명주기(resume/pause/finish)를 DisposableEffect 로 연결한다. + * MapView 단일 인스턴스 생성, start()/MapReadyCallback 캡슐화, 준비된 KakaoMap 을 + * [onMapReady] 로 노출한다. + * + * @param onMapReady 지도 준비 완료 시 KakaoMap 핸들 전달(여기서 MapViewManager 생성 등) + * @param onMapError 지도 로딩 에러 + */ +@Composable +fun KakaoMapView( + modifier: Modifier = Modifier, + onMapReady: (KakaoMap) -> Unit = {}, + onMapError: (Exception) -> Unit = {}, +) { + // Preview 에서는 네이티브 지도가 렌더되지 않으므로 빈 영역으로 early return + if (LocalInspectionMode.current) { + return + } + + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // recomposition 마다 재생성 금지 + val mapView = remember { MapView(context) } + + // 호스트 생명주기를 MapView 의 resume/pause/finish 로 전달 + DisposableEffect(lifecycleOwner, mapView) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> mapView.resume() + Lifecycle.Event.ON_PAUSE -> mapView.pause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapView.finish() // 화면 이탈 시 정리(누수 방지) + } + } + + AndroidView( + modifier = modifier, + factory = { + mapView.apply { + start( + object : MapLifeCycleCallback() { + override fun onMapDestroy() { + Timber.d("KakaoMap destroyed") + } + + override fun onMapError(error: Exception) { + Timber.e(error, "KakaoMap error") + onMapError(error) + } + }, + object : KakaoMapReadyCallback() { + override fun onMapReady(map: KakaoMap) { + onMapReady(map) + } + }, + ) + } + }, + ) +} From e566ed4e73286eba6a8644b984128a329731d0b2 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 29 Jun 2026 22:11:07 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20MatchingMapScreen=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20combinedMapData=20=EA=B5=AC=EB=8F=85=ED=95=B4?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4/=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KakaoMapView 사용, onMapReady에서 MapViewManager 생성 - collectAsStateWithLifecycle(combinedMapData) + LaunchedEffect로 redrawMarkers 호출 - 카메라 idle: 위치 저장 + 영역 이동 시 fetchMatchingMembersIfMoved - 라벨 클릭: 클러스터/내 장소 분기를 콜백으로 상위 위임 - 마커 그리기/클러스터 계산은 기존 MapViewManager/ClusterCalculator 재사용 --- .../matching/map/compose/MatchingMapScreen.kt | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt new file mode 100644 index 00000000..65aeccdb --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt @@ -0,0 +1,118 @@ +package com.project200.feature.matching.map.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kakao.vectormap.label.Label +import com.project200.domain.model.MatchingMember +import com.project200.feature.matching.map.MapViewManager +import com.project200.feature.matching.map.MatchingMapViewModel +import com.project200.feature.matching.map.cluster.ClusterCalculator +import com.project200.feature.matching.map.cluster.MapClusterItem + +/** + * 매칭 지도 화면 + * + * KakaoMapView 로 지도를 호스팅하고, ViewModel 의 combinedMapData 를 구독해 마커/클러스터를 그린다. + * + * 라벨 클릭의 네비게이션/바텀시트는 화면 밖 책임이라 콜백으로 상위에 위임한다. + * + * @param onClusterClick 클러스터 라벨 클릭 시, 묶인 멤버 목록 전달 + * @param onPlaceMarkerClick 내 장소 마커 클릭 시 + */ +@Composable +fun MatchingMapScreen( + viewModel: MatchingMapViewModel, + modifier: Modifier = Modifier, + onClusterClick: (List) -> Unit = {}, + onPlaceMarkerClick: () -> Unit = {}, +) { + val context = LocalContext.current + val mapData by viewModel.combinedMapData.collectAsStateWithLifecycle() + + // 클러스터 계산기 + val clusterCalculator = remember { ClusterCalculator() } + var manager by remember { mutableStateOf(null) } + + Box(modifier = modifier.fillMaxSize()) { + KakaoMapView( + modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + manager = + MapViewManager( + context = context, + kakaoMap = map, + onCameraIdle = { cameraPosition -> + // 카메라 이동 종료 → 위치 저장 + 영역 이동 시 멤버 재조회 + viewModel.saveLastLocation( + cameraPosition.position.latitude, + cameraPosition.position.longitude, + cameraPosition.zoomLevel, + ) + manager?.getCurrentBounds()?.let { bounds -> + viewModel.fetchMatchingMembersIfMoved( + currentBounds = bounds, + currentCenter = cameraPosition.position, + currentZoom = cameraPosition.zoomLevel, + ) + } + }, + onLabelClick = { label -> + handleLabelClick(label, manager, clusterCalculator, onClusterClick, onPlaceMarkerClick) + }, + ) + }, + ) + // 중앙 마커, 현재위치 버튼 등 오버레이는 여기에 추가 + } + + // 데이터(또는 지도 준비) 변경 시 마커/클러스터 다시 그리기 + LaunchedEffect(mapData, manager) { + val mgr = manager ?: return@LaunchedEffect + val (members, places) = mapData + updateClusterData(members, clusterCalculator) + mgr.redrawMarkers(places, clusterCalculator) + } +} + +/** ViewModel 의 최신 멤버를 ClusterCalculator 에 반영. */ +private fun updateClusterData( + members: List, + clusterCalculator: ClusterCalculator, +) { + val clusterItems = + members.flatMap { member -> + member.locations.map { location -> MapClusterItem(member, location) } + } + clusterCalculator.clearItems() + clusterCalculator.addItems(clusterItems) +} + +/** 클릭한 라벨이 클러스터면 멤버 목록 콜백, 아니면 내 장소 마커 콜백. */ +private fun handleLabelClick( + label: Label, + manager: MapViewManager?, + clusterCalculator: ClusterCalculator, + onClusterClick: (List) -> Unit, + onPlaceMarkerClick: () -> Unit, +) { + val cameraPosition = manager?.getCurrentCameraPosition() ?: return + val cluster = + clusterCalculator.getClusters(cameraPosition).find { cluster -> + cluster.position.latitude == label.position.latitude && + cluster.position.longitude == label.position.longitude + } + if (cluster != null) { + onClusterClick(cluster.items.toList()) + } else { + onPlaceMarkerClick() + } +} From 8b8b3e2b3a82252912a17c0a013a624841992c82 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 30 Jun 2026 12:28:01 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20MatchingMapFragment=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=B3=B8=EC=B2=B4=20ComposeView=EB=A1=9C?= =?UTF-8?q?=20=EA=B5=90=EC=B2=B4=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fragment_matching_map.xml: MapView → ComposeView(MatchingMapScreen) - 지도/마커/클러스터/라벨클릭을 Screen에 위임, Fragment는 필터·다이얼로그·옵저버 유지 - 현재위치 버튼/초기 카메라 복원은 카메라 제어 통로 필요 → TODO(#546)로 일시 보류 --- .../matching/map/MatchingMapFragment.kt | 246 ++---------------- .../matching/map/compose/KakaoMapView.kt | 7 +- .../matching/map/compose/MatchingMapScreen.kt | 5 +- .../main/res/layout/fragment_matching_map.xml | 4 +- 4 files changed, 24 insertions(+), 238 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt index 643a3c00..2e59a6b0 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt @@ -1,122 +1,62 @@ package com.project200.feature.matching.map -import android.Manifest -import android.annotation.SuppressLint -import android.content.pm.PackageManager import android.view.View import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationServices -import com.kakao.vectormap.KakaoMap -import com.kakao.vectormap.KakaoMapReadyCallback -import com.kakao.vectormap.LatLng -import com.kakao.vectormap.MapLifeCycleCallback -import com.kakao.vectormap.camera.CameraPosition -import com.kakao.vectormap.label.Label -import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LATITUDE -import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LONGITUDE -import com.project200.common.constants.RuleConstants.ZOOM_LEVEL -import com.project200.domain.model.MapPosition -import com.project200.domain.model.MatchingMember -import com.project200.feature.matching.map.cluster.ClusterCalculator import com.project200.feature.matching.map.cluster.MapClusterItem +import com.project200.feature.matching.map.compose.MatchingMapScreen import com.project200.feature.matching.map.filter.FilterBottomSheetDialog import com.project200.feature.matching.map.filter.MatchingFilterRVAdapter import com.project200.feature.matching.utils.MatchingFilterType import com.project200.presentation.base.BindingFragment +import com.project200.presentation.compose.applyAppTheme import com.project200.undabang.feature.matching.R import com.project200.undabang.feature.matching.databinding.FragmentMatchingMapBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import timber.log.Timber @AndroidEntryPoint class MatchingMapFragment : BindingFragment(R.layout.fragment_matching_map) { - // MapViewManager로 지도 관련 로직 위임 - private var mapViewManager: MapViewManager? = null - - private lateinit var fusedLocationClient: FusedLocationProviderClient private val viewModel: MatchingMapViewModel by viewModels() private val filterAdapter by lazy { MatchingFilterRVAdapter( - onFilterClick = { type -> - viewModel.onFilterTypeClicked(type) - }, - onClearClick = { - viewModel.clearFilters() - }, + onFilterClick = { type -> viewModel.onFilterTypeClicked(type) }, + onClearClick = { viewModel.clearFilters() }, ) } - private var isMapInitialized: Boolean = false - - // 클러스터링 계산을 위한 헬퍼 클래스 - private lateinit var clusterCalculator: ClusterCalculator - - // 위치 권한 요청을 위한 ActivityResultLauncher 정의 - private val locationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) { - // 권한이 허용되면 현재 위치로 이동 - moveToCurrentLocation() - } - } - override fun getViewBinding(view: View): FragmentMatchingMapBinding { return FragmentMatchingMapBinding.bind(view) } override fun setupViews() { - // 클러스터 계산기 초기화 - clusterCalculator = ClusterCalculator() - isMapInitialized = false - fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireActivity()) + binding.mapComposeView.applyAppTheme { + MatchingMapScreen( + viewModel = viewModel, + onClusterClick = { items -> showMembersBottomSheet(items) }, + onPlaceMarkerClick = { + findNavController().navigate( + MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment(), + ) + }, + ) + } - initMapView() initListeners() binding.matchingFilterRv.adapter = filterAdapter filterAdapter.submitFilterList(MatchingFilterType.entries) } - private fun initMapView() { - binding.mapView.start( - object : MapLifeCycleCallback() { - override fun onMapDestroy() {} - - override fun onMapError(error: Exception) { - Timber.d("$error") - } - }, - object : KakaoMapReadyCallback() { - override fun onMapReady(map: KakaoMap) { - // MapViewManager를 생성하여 지도 로직을 위임 - mapViewManager = - MapViewManager( - context = requireContext(), - kakaoMap = map, - onCameraIdle = { cameraPosition -> handleCamera(cameraPosition) }, - onLabelClick = { label -> handleLabelClick(label) }, - ) - - setupMapRelatedObservers() - } - }, - ) - } - private fun initListeners() { binding.currentLocationBtn.setOnClickListener { - checkPermissionAndMove() + // TODO: 현재 위치 이동 구현 } binding.exercisePlaceListBtn.setOnClickListener { @@ -126,48 +66,6 @@ class MatchingMapFragment : } } - /** - * MapViewManager로부터 카메라 이동 완료 이벤트를 받아 처리합니다. - */ - private fun handleCamera(cameraPosition: CameraPosition) { - // 마지막 위치 저장 - viewModel.saveLastLocation( - cameraPosition.position.latitude, - cameraPosition.position.longitude, - cameraPosition.zoomLevel, - ) - - // 현재 화면 영역(Bounds) 조회 - val currentBounds = mapViewManager?.getCurrentBounds() ?: return - - // 화면 이동에 따라 매칭 멤버 데이터 갱신 - viewModel.fetchMatchingMembersIfMoved( - currentBounds = currentBounds, - currentCenter = cameraPosition.position, - currentZoom = cameraPosition.zoomLevel, - ) - } - - /** - * MapViewManager로부터 라벨 클릭 이벤트를 받아 처리합니다. - */ - private fun handleLabelClick(label: Label) { - val cameraPosition = mapViewManager?.getCurrentCameraPosition() ?: return - - // 클릭한 라벨이 클러스터인지 확인 - clusterCalculator.getClusters(cameraPosition).find { cluster -> - cluster.position.latitude == label.position.latitude && - cluster.position.longitude == label.position.longitude - }?.let { foundCluster -> - // 클러스터에 있는 운동 장소 리스트 표시 - showMembersBottomSheet(foundCluster.items.toList()) - return - } - - // 클러스터를 찾지 못한 경우 == 내 장소 마커를 클릭한 경우 - findNavController().navigate(MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment()) - } - override fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -216,74 +114,6 @@ class MatchingMapFragment : } } - /** - * 지도와 직접적으로 관련된 옵저버들을 설정합니다. - */ - private fun setupMapRelatedObservers() { - // 초기 지도 위치 관찰 - viewModel.initialMapPosition.observe(viewLifecycleOwner) { savedPosition -> - // 지도의 초기 위치 설정이 아직 완료되지 않았을 때만 카메라를 이동시킵니다. - if (isMapInitialized) return@observe - - if (isLocationPermissionGranted()) { // 권한이 있는 경우 - if (savedPosition != null) { - mapViewManager?.moveCamera( - LatLng.from(savedPosition.latitude, savedPosition.longitude), - savedPosition.zoomLevel, - ) - } else { - // 저장된 위치가 없으면, 현재 위치로 이동 - moveToCurrentLocation() - } - } else { - // 위치 권한이 없는 경우 - // 기본위치(서울시청)로 이동 - val defaultPosition = - MapPosition( - latitude = SEOUL_CITY_HALL_LATITUDE, - longitude = SEOUL_CITY_HALL_LONGITUDE, - zoomLevel = ZOOM_LEVEL, - ) - - mapViewManager?.moveCamera( - LatLng.from(defaultPosition.latitude, defaultPosition.longitude), - defaultPosition.zoomLevel, - ) - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - - isMapInitialized = true - } - - // 회원 및 장소 데이터 통합 관찰 - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.combinedMapData.collect { (members, places) -> - // 클러스터 계산기에 최신 회원 데이터 업데이트 - updateClusterData(members) - - // 데이터가 변경되었으므로 마커 redraw - mapViewManager?.redrawMarkers(places, clusterCalculator) - } - } - } - } - - /** - * ViewModel의 최신 데이터를 ClusterCalculator에 업데이트합니다. - */ - private fun updateClusterData(members: List) { - val clusterItems = mutableListOf() - members.forEach { member -> - member.locations.forEach { location -> - clusterItems.add(MapClusterItem(member, location)) - } - } - - clusterCalculator.clearItems() - clusterCalculator.addItems(clusterItems) - } - private fun showPlaceGuideDialog() { val dialog = MatchingPlaceGuideDialog( @@ -293,7 +123,7 @@ class MatchingMapFragment : ) }, ) - dialog.isCancelable = false // 바깥 영역 터치 시 다이얼로그가 닫히지 않도록 설정 + dialog.isCancelable = false dialog.show(parentFragmentManager, this::class.java.simpleName) } @@ -321,50 +151,8 @@ class MatchingMapFragment : bottomSheet.show(childFragmentManager, FilterBottomSheetDialog::class.java.simpleName) } - private fun checkPermissionAndMove() { - if (isLocationPermissionGranted()) { - moveToCurrentLocation() - } else { - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - - /** - * 현재 위치로 카메라를 이동시키는 함수 - */ - @SuppressLint("MissingPermission") // 권한 체크는 isLocationPermissionGranted()로 이미 수행됨 - private fun moveToCurrentLocation() { - fusedLocationClient.lastLocation.addOnSuccessListener { location -> - val latLng: LatLng - if (location != null) { - latLng = LatLng.from(location.latitude, location.longitude) - } else { - latLng = LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE) - Toast.makeText( - requireContext(), - R.string.error_cannot_find_current_location, - Toast.LENGTH_SHORT, - ).show() - } - mapViewManager?.moveCamera(latLng, ZOOM_LEVEL) - } - } - - private fun isLocationPermissionGranted(): Boolean { - return ContextCompat.checkSelfPermission( - requireContext(), - Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - } - override fun onResume() { super.onResume() - binding.mapView.resume() viewModel.refreshExercisePlaces() } - - override fun onPause() { - super.onPause() - binding.mapView.pause() - } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt index b01c95cc..c7a4e429 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt @@ -17,7 +17,6 @@ import com.kakao.vectormap.MapView import timber.log.Timber /** - * KakaoMap v2(MapView) 를 Compose 에서 사용하기 위한 재사용 Composable * * MapView 를 AndroidView 로 호스팅하고, * 생명주기(resume/pause/finish)를 DisposableEffect 로 연결한다. @@ -33,7 +32,7 @@ fun KakaoMapView( onMapReady: (KakaoMap) -> Unit = {}, onMapError: (Exception) -> Unit = {}, ) { - // Preview 에서는 네이티브 지도가 렌더되지 않으므로 빈 영역으로 early return + // Preview 에서는 빈 영역으로 return if (LocalInspectionMode.current) { return } @@ -41,7 +40,7 @@ fun KakaoMapView( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - // recomposition 마다 재생성 금지 + // recomposition 마다 재생성 방지하기 위해 remember 사용 val mapView = remember { MapView(context) } // 호스트 생명주기를 MapView 의 resume/pause/finish 로 전달 @@ -57,7 +56,7 @@ fun KakaoMapView( lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - mapView.finish() // 화면 이탈 시 정리(누수 방지) + mapView.finish() } } diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt index 65aeccdb..7bd69669 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt @@ -23,8 +23,6 @@ import com.project200.feature.matching.map.cluster.MapClusterItem * * KakaoMapView 로 지도를 호스팅하고, ViewModel 의 combinedMapData 를 구독해 마커/클러스터를 그린다. * - * 라벨 클릭의 네비게이션/바텀시트는 화면 밖 책임이라 콜백으로 상위에 위임한다. - * * @param onClusterClick 클러스터 라벨 클릭 시, 묶인 멤버 목록 전달 * @param onPlaceMarkerClick 내 장소 마커 클릭 시 */ @@ -36,6 +34,7 @@ fun MatchingMapScreen( onPlaceMarkerClick: () -> Unit = {}, ) { val context = LocalContext.current + // 멤버 데이터 구독 val mapData by viewModel.combinedMapData.collectAsStateWithLifecycle() // 클러스터 계산기 @@ -71,7 +70,7 @@ fun MatchingMapScreen( ) }, ) - // 중앙 마커, 현재위치 버튼 등 오버레이는 여기에 추가 + // TODO: 필터, 현재위치 버튼 등 오버레이 여기에 추가 } // 데이터(또는 지도 준비) 변경 시 마커/클러스터 다시 그리기 diff --git a/feature/matching/src/main/res/layout/fragment_matching_map.xml b/feature/matching/src/main/res/layout/fragment_matching_map.xml index ebf7568e..a722942d 100644 --- a/feature/matching/src/main/res/layout/fragment_matching_map.xml +++ b/feature/matching/src/main/res/layout/fragment_matching_map.xml @@ -35,8 +35,8 @@ android:paddingStart="@dimen/base_horizontal_margin" android:paddingEnd="10dp"/> - Date: Tue, 30 Jun 2026 15:57:44 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20MatchingMapViewModel=20initia?= =?UTF-8?q?lMapPosition=EC=9D=84=20StateFlow=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveData → StateFlow - Compose에서 일관되게 다루기 위함. .value 호환이라 기존 테스트 무수정 통과 --- .../project200/feature/matching/map/MatchingMapViewModel.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt index 8b6c5fd0..0b525586 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt @@ -1,8 +1,6 @@ package com.project200.feature.matching.map import android.content.SharedPreferences -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kakao.vectormap.LatLng @@ -126,8 +124,8 @@ class MatchingMapViewModel ) // 지도 초기 위치 - private val _initialMapPosition = MutableLiveData() - val initialMapPosition: LiveData = _initialMapPosition + private val _initialMapPosition = MutableStateFlow(null) + val initialMapPosition: StateFlow = _initialMapPosition.asStateFlow() // 운동 장소 다이얼로그 표시 알림 private val _shouldShowPlaceGuideDialog = MutableSharedFlow() From 3d21778738dffbf4c4844653bbac12f57e22d78e Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 30 Jun 2026 16:04:15 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20MatchingMapController?= =?UTF-8?q?=EB=A1=9C=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?+=20=ED=98=84=EC=9E=AC=EC=9C=84=EC=B9=98/=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EB=B3=B5=EC=9B=90=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MatchingMapController 추가: Screen 내부 MapViewManager에 다리를 놓아 화면 밖(Fragment)에서 moveCamera/currentBounds 호출 - MatchingMapScreen: controller/onMapReady 파라미터 추가, 지도 준비 시 통로 연결 - MatchingMapFragment: 현재위치 버튼·초기 카메라 복원을 controller 경유로 복원 (권한/FusedLocation은 Fragment 책임 유지) --- .../matching/map/MatchingMapFragment.kt | 91 ++++++++++++++++++- .../map/compose/MatchingMapController.kt | 28 ++++++ .../matching/map/compose/MatchingMapScreen.kt | 9 +- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt index 2e59a6b0..6822f9e9 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt @@ -1,14 +1,26 @@ package com.project200.feature.matching.map +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager import android.view.View import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.kakao.vectormap.LatLng +import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LATITUDE +import com.project200.common.constants.RuleConstants.SEOUL_CITY_HALL_LONGITUDE +import com.project200.common.constants.RuleConstants.ZOOM_LEVEL import com.project200.feature.matching.map.cluster.MapClusterItem +import com.project200.feature.matching.map.compose.MatchingMapController import com.project200.feature.matching.map.compose.MatchingMapScreen import com.project200.feature.matching.map.filter.FilterBottomSheetDialog import com.project200.feature.matching.map.filter.MatchingFilterRVAdapter @@ -25,6 +37,11 @@ class MatchingMapFragment : BindingFragment(R.layout.fragment_matching_map) { private val viewModel: MatchingMapViewModel by viewModels() + // 지도 본체(MatchingMapScreen) 내부 카메라를 제어하기 위한 통로 + private val mapController = MatchingMapController() + private var isMapInitialized = false + private lateinit var fusedLocationClient: FusedLocationProviderClient + private val filterAdapter by lazy { MatchingFilterRVAdapter( onFilterClick = { type -> viewModel.onFilterTypeClicked(type) }, @@ -32,14 +49,25 @@ class MatchingMapFragment : ) } + private val locationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) moveToCurrentLocation() + } + override fun getViewBinding(view: View): FragmentMatchingMapBinding { return FragmentMatchingMapBinding.bind(view) } override fun setupViews() { + isMapInitialized = false + fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireActivity()) + + // 지도 본체는 Compose(MatchingMapScreen)로 호스팅. 마커/클러스터/카메라 idle은 Screen 내부 처리. binding.mapComposeView.applyAppTheme { MatchingMapScreen( viewModel = viewModel, + controller = mapController, + onMapReady = { restoreInitialCamera() }, onClusterClick = { items -> showMembersBottomSheet(items) }, onPlaceMarkerClick = { findNavController().navigate( @@ -56,7 +84,7 @@ class MatchingMapFragment : private fun initListeners() { binding.currentLocationBtn.setOnClickListener { - // TODO: 현재 위치 이동 구현 + checkPermissionAndMove() } binding.exercisePlaceListBtn.setOnClickListener { @@ -66,6 +94,67 @@ class MatchingMapFragment : } } + /** + * 지도가 준비되면 1회 호출되어 초기 카메라 위치를 복원한다. + * 저장된 위치가 있으면 그곳으로, 없으면 현재 위치(권한 시) 또는 기본 위치(서울시청)로 이동한다. + */ + private fun restoreInitialCamera() { + if (isMapInitialized) return + + val savedPosition = viewModel.initialMapPosition.value + if (isLocationPermissionGranted()) { + if (savedPosition != null) { + mapController.moveCamera( + LatLng.from(savedPosition.latitude, savedPosition.longitude), + savedPosition.zoomLevel, + ) + } else { + moveToCurrentLocation() + } + } else { + mapController.moveCamera( + LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE), + ZOOM_LEVEL, + ) + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + + isMapInitialized = true + } + + private fun checkPermissionAndMove() { + if (isLocationPermissionGranted()) { + moveToCurrentLocation() + } else { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + @SuppressLint("MissingPermission") // 권한은 isLocationPermissionGranted()로 이미 확인됨 + private fun moveToCurrentLocation() { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + val latLng = + if (location != null) { + LatLng.from(location.latitude, location.longitude) + } else { + Toast.makeText( + requireContext(), + R.string.error_cannot_find_current_location, + Toast.LENGTH_SHORT, + ).show() + LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE) + } + mapController.moveCamera(latLng, ZOOM_LEVEL) + } + } + + private fun isLocationPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + } + override fun setupObservers() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt new file mode 100644 index 00000000..948af6fc --- /dev/null +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt @@ -0,0 +1,28 @@ +package com.project200.feature.matching.map.compose + +import com.kakao.vectormap.LatLng +import com.project200.domain.model.MapBounds +import com.project200.feature.matching.map.MapViewManager + +/** + * 지도 카메라를 화면 밖(Fragment)에서 제어하기 위한 통로. + * + * MapViewManager 는 MatchingMapScreen 내부에서 비동기로 생성되므로, + * 권한/현재위치/초기 복원처럼 Fragment 가 담당하는 로직이 카메라를 만지려면 이 컨트롤러로 다리를 놓는다. + * 준비 전(manager == null)에는 카메라 명령이 무시된다. + */ +class MatchingMapController { + internal var manager: MapViewManager? = null + + val isReady: Boolean + get() = manager != null + + fun moveCamera( + latLng: LatLng, + zoomLevel: Int, + ) { + manager?.moveCamera(latLng, zoomLevel) + } + + fun currentBounds(): MapBounds? = manager?.getCurrentBounds() +} diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt index 7bd69669..91778055 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapScreen.kt @@ -30,6 +30,8 @@ import com.project200.feature.matching.map.cluster.MapClusterItem fun MatchingMapScreen( viewModel: MatchingMapViewModel, modifier: Modifier = Modifier, + controller: MatchingMapController? = null, + onMapReady: () -> Unit = {}, onClusterClick: (List) -> Unit = {}, onPlaceMarkerClick: () -> Unit = {}, ) { @@ -67,10 +69,13 @@ fun MatchingMapScreen( onLabelClick = { label -> handleLabelClick(label, manager, clusterCalculator, onClusterClick, onPlaceMarkerClick) }, - ) + ).also { mgr -> + controller?.manager = mgr // 카메라 제어 통로 연결 + onMapReady() // Fragment가 초기 복원 등을 시작하도록 통지 + } }, ) - // TODO: 필터, 현재위치 버튼 등 오버레이 여기에 추가 + // 현재위치 버튼 등 오버레이는 Fragment XML 유지(추후 Compose 전환) } // 데이터(또는 지도 준비) 변경 시 마커/클러스터 다시 그리기 From 667cdbc9bd389c2c377363739c69cdf99562206a Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 2 Jul 2026 23:47:46 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20MatchingMapController?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20isReady/curr?= =?UTF-8?q?entBounds=20=EC=A0=9C=EA=B1=B0=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호출부가 없는 죽은 코드 정리. moveCamera 는 manager? 로 이미 null-safe 하게 동작하므로 isReady 사전 체크가 불필요하고, 지도 영역은 Screen 의 onCameraIdle 에서 manager.getCurrentBounds() 로 직접 구해 currentBounds() 통로도 쓰이지 않는다. --- .../feature/matching/map/compose/MatchingMapController.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt index 948af6fc..26ea5a33 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/MatchingMapController.kt @@ -1,7 +1,6 @@ package com.project200.feature.matching.map.compose import com.kakao.vectormap.LatLng -import com.project200.domain.model.MapBounds import com.project200.feature.matching.map.MapViewManager /** @@ -14,15 +13,10 @@ import com.project200.feature.matching.map.MapViewManager class MatchingMapController { internal var manager: MapViewManager? = null - val isReady: Boolean - get() = manager != null - fun moveCamera( latLng: LatLng, zoomLevel: Int, ) { manager?.moveCamera(latLng, zoomLevel) } - - fun currentBounds(): MapBounds? = manager?.getCurrentBounds() } From 6722a44cca0325eb563b38b67049c8476fc413a4 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:52:50 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20KakaoMapView=20Preview=20?= =?UTF-8?q?=EC=8B=9C=20modifier=20=EC=A0=81=EC=9A=A9=20placeholder=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/matching/map/compose/KakaoMapView.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt index c7a4e429..957b7ad6 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt @@ -1,8 +1,12 @@ package com.project200.feature.matching.map.compose +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -14,6 +18,8 @@ import com.kakao.vectormap.KakaoMap import com.kakao.vectormap.KakaoMapReadyCallback import com.kakao.vectormap.MapLifeCycleCallback import com.kakao.vectormap.MapView +import com.project200.presentation.compose.theme.ColorGray100 +import com.project200.presentation.compose.theme.ColorGray300 import timber.log.Timber /** @@ -32,8 +38,14 @@ fun KakaoMapView( onMapReady: (KakaoMap) -> Unit = {}, onMapError: (Exception) -> Unit = {}, ) { - // Preview 에서는 빈 영역으로 return + // Preview 에서는 실제 지도 대신 modifier 크기만큼의 placeholder 를 그린다 if (LocalInspectionMode.current) { + Box( + modifier = modifier.background(ColorGray300), + contentAlignment = Alignment.Center, + ) { + Text(text = "지도 미리보기", color = ColorGray100) + } return } From 17a5225912cd47ca32628c0a7b7550f7555f5e38 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:53:00 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20KakaoMapView=20MapView=20reme?= =?UTF-8?q?mber=20=ED=82=A4=EC=97=90=20context=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/feature/matching/map/compose/KakaoMapView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt index 957b7ad6..01b73012 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt @@ -52,8 +52,8 @@ fun KakaoMapView( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - // recomposition 마다 재생성 방지하기 위해 remember 사용 - val mapView = remember { MapView(context) } + // recomposition 마다 재생성 방지하기 위해 remember 사용 (context 변경 시에는 재생성) + val mapView = remember(context) { MapView(context) } // 호스트 생명주기를 MapView 의 resume/pause/finish 로 전달 DisposableEffect(lifecycleOwner, mapView) { From 58e06269694d9dcd512b954d8a823551dc51e19e Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:13:32 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20KakaoMapView=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20rememberUpdatedState=EB=A1=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=EC=B0=B8=EC=A1=B0=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/matching/map/compose/KakaoMapView.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt index 01b73012..8b64358d 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/compose/KakaoMapView.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -55,6 +57,10 @@ fun KakaoMapView( // recomposition 마다 재생성 방지하기 위해 remember 사용 (context 변경 시에는 재생성) val mapView = remember(context) { MapView(context) } + // factory/콜백은 최초 1회만 생성되므로, 상위가 새 람다를 넘겨도 항상 최신 콜백을 참조하도록 한다 + val currentOnMapReady by rememberUpdatedState(onMapReady) + val currentOnMapError by rememberUpdatedState(onMapError) + // 호스트 생명주기를 MapView 의 resume/pause/finish 로 전달 DisposableEffect(lifecycleOwner, mapView) { val observer = @@ -84,12 +90,12 @@ fun KakaoMapView( override fun onMapError(error: Exception) { Timber.e(error, "KakaoMap error") - onMapError(error) + currentOnMapError(error) } }, object : KakaoMapReadyCallback() { override fun onMapReady(map: KakaoMap) { - onMapReady(map) + currentOnMapReady(map) } }, ) From 48e6775cf74783512f6bba2e2e69235401aa3520 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 7 Jul 2026 20:50:02 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20moveToCurrentLocation?= =?UTF-8?q?=EC=97=90=20getLastLocation=20Failure=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=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 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../matching/map/MatchingMapFragment.kt | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt index 6822f9e9..717cd283 100644 --- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt +++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt @@ -31,6 +31,7 @@ import com.project200.undabang.feature.matching.R import com.project200.undabang.feature.matching.databinding.FragmentMatchingMapBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import timber.log.Timber @AndroidEntryPoint class MatchingMapFragment : @@ -132,20 +133,33 @@ class MatchingMapFragment : @SuppressLint("MissingPermission") // 권한은 isLocationPermissionGranted()로 이미 확인됨 private fun moveToCurrentLocation() { - fusedLocationClient.lastLocation.addOnSuccessListener { location -> - val latLng = + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> if (location != null) { - LatLng.from(location.latitude, location.longitude) + mapController.moveCamera( + LatLng.from(location.latitude, location.longitude), + ZOOM_LEVEL, + ) } else { - Toast.makeText( - requireContext(), - R.string.error_cannot_find_current_location, - Toast.LENGTH_SHORT, - ).show() - LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE) + fallbackToDefaultLocation(cause = null) } - mapController.moveCamera(latLng, ZOOM_LEVEL) - } + } + .addOnFailureListener { e -> + fallbackToDefaultLocation(cause = e) + } + } + + private fun fallbackToDefaultLocation(cause: Throwable?) { + cause?.let { Timber.e(it, "getLastLocation failed") } + Toast.makeText( + requireContext(), + R.string.error_cannot_find_current_location, + Toast.LENGTH_SHORT, + ).show() + mapController.moveCamera( + LatLng.from(SEOUL_CITY_HALL_LATITUDE, SEOUL_CITY_HALL_LONGITUDE), + ZOOM_LEVEL, + ) } private fun isLocationPermissionGranted(): Boolean {