Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,18 @@ 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.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
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
Expand All @@ -41,79 +36,53 @@ import timber.log.Timber
@AndroidEntryPoint
class MatchingMapFragment :
BindingFragment<FragmentMatchingMapBinding>(R.layout.fragment_matching_map) {
// MapViewManager로 지도 관련 로직 위임
private var mapViewManager: MapViewManager? = null
private val viewModel: MatchingMapViewModel by viewModels()

// 지도 본체(MatchingMapScreen) 내부 카메라를 제어하기 위한 통로
private val mapController = MatchingMapController()
private var isMapInitialized = false
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<MapClusterItem>

// 위치 권한 요청을 위한 ActivityResultLauncher 정의
private val locationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
// 권한이 허용되면 현재 위치로 이동
moveToCurrentLocation()
}
if (isGranted) moveToCurrentLocation()
}

override fun getViewBinding(view: View): FragmentMatchingMapBinding {
return FragmentMatchingMapBinding.bind(view)
}

override fun setupViews() {
// 클러스터 계산기 초기화
clusterCalculator = ClusterCalculator()
isMapInitialized = false
fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireActivity())

initMapView()
// 지도 본체는 Compose(MatchingMapScreen)로 호스팅. 마커/클러스터/카메라 idle은 Screen 내부 처리.
binding.mapComposeView.applyAppTheme {
MatchingMapScreen(
viewModel = viewModel,
controller = mapController,
onMapReady = { restoreInitialCamera() },
onClusterClick = { items -> showMembersBottomSheet(items) },
onPlaceMarkerClick = {
findNavController().navigate(
MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment(),
)
},
)
}

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()
Expand All @@ -127,45 +96,77 @@ class MatchingMapFragment :
}

/**
* MapViewManager로부터 카메라 이동 완료 이벤트를 받아 처리합니다.
* 지도가 준비되면 1회 호출되어 초기 카메라 위치를 복원한다.
* 저장된 위치가 있으면 그곳으로, 없으면 현재 위치(권한 시) 또는 기본 위치(서울시청)로 이동한다.
*/
private fun handleCamera(cameraPosition: CameraPosition) {
// 마지막 위치 저장
viewModel.saveLastLocation(
cameraPosition.position.latitude,
cameraPosition.position.longitude,
cameraPosition.zoomLevel,
)
private fun restoreInitialCamera() {
if (isMapInitialized) return

// 현재 화면 영역(Bounds) 조회
val currentBounds = mapViewManager?.getCurrentBounds() ?: 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)
}

// 화면 이동에 따라 매칭 멤버 데이터 갱신
viewModel.fetchMatchingMembersIfMoved(
currentBounds = currentBounds,
currentCenter = cameraPosition.position,
currentZoom = cameraPosition.zoomLevel,
)
isMapInitialized = true
}

/**
* 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
private fun checkPermissionAndMove() {
if (isLocationPermissionGranted()) {
moveToCurrentLocation()
} else {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}

@SuppressLint("MissingPermission") // 권한은 isLocationPermissionGranted()로 이미 확인됨
private fun moveToCurrentLocation() {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
if (location != null) {
mapController.moveCamera(
LatLng.from(location.latitude, location.longitude),
ZOOM_LEVEL,
)
} else {
fallbackToDefaultLocation(cause = null)
}
}
.addOnFailureListener { e ->
fallbackToDefaultLocation(cause = e)
}
}

// 클러스터를 찾지 못한 경우 == 내 장소 마커를 클릭한 경우
findNavController().navigate(MatchingMapFragmentDirections.actionMatchingMapFragmentToExercisePlaceFragment())
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 {
return ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
}

override fun setupObservers() {
Expand Down Expand Up @@ -216,74 +217,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<MatchingMember>) {
val clusterItems = mutableListOf<MapClusterItem>()
members.forEach { member ->
member.locations.forEach { location ->
clusterItems.add(MapClusterItem(member, location))
}
}

clusterCalculator.clearItems()
clusterCalculator.addItems(clusterItems)
}

private fun showPlaceGuideDialog() {
val dialog =
MatchingPlaceGuideDialog(
Expand All @@ -293,7 +226,7 @@ class MatchingMapFragment :
)
},
)
dialog.isCancelable = false // 바깥 영역 터치 시 다이얼로그가 닫히지 않도록 설정
dialog.isCancelable = false
dialog.show(parentFragmentManager, this::class.java.simpleName)
}

Expand Down Expand Up @@ -321,50 +254,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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -126,8 +124,8 @@ class MatchingMapViewModel
)

// 지도 초기 위치
private val _initialMapPosition = MutableLiveData<MapPosition?>()
val initialMapPosition: LiveData<MapPosition?> = _initialMapPosition
private val _initialMapPosition = MutableStateFlow<MapPosition?>(null)
val initialMapPosition: StateFlow<MapPosition?> = _initialMapPosition.asStateFlow()

// 운동 장소 다이얼로그 표시 알림
private val _shouldShowPlaceGuideDialog = MutableSharedFlow<Unit>()
Expand Down
Loading
Loading