33package com.eatssu.android.presentation.map
44
55import android.Manifest
6+ import android.R.id.message
67import android.app.Activity
78import android.content.Context
89import android.content.ContextWrapper
@@ -11,11 +12,19 @@ import android.content.pm.PackageManager
1112import android.widget.Toast
1213import androidx.activity.compose.rememberLauncherForActivityResult
1314import androidx.activity.result.contract.ActivityResultContracts
15+ import androidx.compose.foundation.Image
16+ import androidx.compose.foundation.background
17+ import androidx.compose.foundation.border
18+ import androidx.compose.foundation.layout.Arrangement.spacedBy
1419import androidx.compose.foundation.layout.Box
1520import androidx.compose.foundation.layout.PaddingValues
21+ import androidx.compose.foundation.layout.Row
1622import androidx.compose.foundation.layout.fillMaxSize
1723import androidx.compose.foundation.layout.fillMaxWidth
1824import androidx.compose.foundation.layout.padding
25+ import androidx.compose.foundation.layout.size
26+ import androidx.compose.foundation.shape.CircleShape
27+ import androidx.compose.foundation.shape.RoundedCornerShape
1928import androidx.compose.material3.CenterAlignedTopAppBar
2029import androidx.compose.material3.ExperimentalMaterial3Api
2130import androidx.compose.material3.Scaffold
@@ -31,8 +40,10 @@ import androidx.compose.runtime.remember
3140import androidx.compose.runtime.rememberCoroutineScope
3241import androidx.compose.ui.Alignment
3342import androidx.compose.ui.Modifier
43+ import androidx.compose.ui.graphics.Color
3444import androidx.compose.ui.platform.LocalContext
3545import androidx.compose.ui.res.dimensionResource
46+ import androidx.compose.ui.res.painterResource
3647import androidx.compose.ui.res.stringResource
3748import androidx.compose.ui.tooling.preview.Preview
3849import androidx.compose.ui.unit.dp
@@ -44,6 +55,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
4455import androidx.lifecycle.compose.collectAsStateWithLifecycle
4556import androidx.lifecycle.viewmodel.compose.viewModel
4657import com.eatssu.android.R
58+ import com.eatssu.android.domain.model.Partnership
4759import com.eatssu.android.domain.model.RestaurantType
4860import com.eatssu.android.presentation.MainState
4961import com.eatssu.android.presentation.MainViewModel
@@ -53,25 +65,28 @@ import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet
5365import com.eatssu.android.presentation.map.component.PartnershipFilterToggle
5466import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity
5567import com.eatssu.android.presentation.util.TrackScreenViewEvent
68+ import com.eatssu.android.presentation.util.showToast
5669import com.eatssu.common.EventLogger
5770import com.eatssu.common.UiEvent
5871import com.eatssu.common.UiState
72+ import com.eatssu.common.UiText
5973import com.eatssu.common.enums.ScreenId
60- import com.eatssu.design_system.theme.Black
74+ import com.eatssu.common.enums.ToastType
6175import com.eatssu.design_system.theme.EatssuTheme
76+ import com.eatssu.design_system.theme.Gray300
77+ import com.eatssu.design_system.theme.Primary
6278import com.naver.maps.geometry.LatLng
6379import com.naver.maps.map.CameraPosition
64- import com.naver.maps.map.compose.Align
80+ import com.naver.maps.map.CameraUpdate
81+ import com.naver.maps.map.clustering.ClusteringKey
6582import com.naver.maps.map.compose.CameraPositionState
83+ import com.naver.maps.map.compose.Clustering
6684import com.naver.maps.map.compose.ExperimentalNaverMapApi
6785import com.naver.maps.map.compose.LocationTrackingMode
6886import com.naver.maps.map.compose.MapProperties
6987import com.naver.maps.map.compose.MapUiSettings
70- import com.naver.maps.map.compose.Marker
7188import com.naver.maps.map.compose.NaverMap
7289import com.naver.maps.map.compose.rememberCameraPositionState
73- import com.naver.maps.map.compose.rememberMarkerState
74- import com.naver.maps.map.overlay.OverlayImage
7590import com.naver.maps.map.util.FusedLocationSource
7691import kotlinx.coroutines.flow.collectLatest
7792import kotlinx.coroutines.launch
@@ -124,7 +139,10 @@ fun MapRoute(
124139 ) { permissions ->
125140 val granted = permissions.values.all { it }
126141 if (! granted) {
127- Toast .makeText(context, context.getString(R .string.dialog_location_permission_description), Toast .LENGTH_SHORT ).show()
142+ context.showToast(
143+ UiText .StringResource (R .string.dialog_location_permission_description),
144+ ToastType .INFO
145+ )
128146 }
129147 }
130148
@@ -143,15 +161,17 @@ fun MapRoute(
143161 LaunchedEffect (Unit ) {
144162 viewModel.uiEvent.collectLatest { event ->
145163 when (event) {
146- is UiEvent .ShowToast -> Toast .makeText( context, event.message.asString(context), Toast . LENGTH_SHORT ).show( )
164+ is UiEvent .ShowToast -> context.showToast(event )
147165 }
148166 }
149167 }
150168
151169 // 최초 실행 시 위치 권한 요청
152170 LaunchedEffect (Unit ) {
153- val fine = ContextCompat .checkSelfPermission(context, Manifest .permission.ACCESS_FINE_LOCATION )
154- val coarse = ContextCompat .checkSelfPermission(context, Manifest .permission.ACCESS_COARSE_LOCATION )
171+ val fine =
172+ ContextCompat .checkSelfPermission(context, Manifest .permission.ACCESS_FINE_LOCATION )
173+ val coarse =
174+ ContextCompat .checkSelfPermission(context, Manifest .permission.ACCESS_COARSE_LOCATION )
155175
156176 if (fine != PackageManager .PERMISSION_GRANTED || coarse != PackageManager .PERMISSION_GRANTED ) {
157177 permissionLauncher.launch(
@@ -214,9 +234,9 @@ fun MapRoute(
214234 locationSource = locationSource,
215235 departmentSheetState = departmentSheetState,
216236 partnershipSheetState = partnershipSheetState,
217- showToast = { message ->
237+ showToast = { uiText, info ->
218238 scope.launch {
219- Toast .makeText(context, message, Toast . LENGTH_SHORT ).show( )
239+ context.showToast(uiText, info )
220240 }
221241 },
222242 navigateToUserInfo = {
@@ -229,6 +249,18 @@ fun MapRoute(
229249 onHidePartnershipSheet = {
230250 scope.launch { partnershipSheetState.hide() }
231251 },
252+ animateCameraPositionTo = { position, currentZoom ->
253+ scope.launch {
254+ cameraPositionState.animate(
255+ CameraUpdate .toCameraPosition(
256+ CameraPosition (
257+ position,
258+ currentZoom + 2.0
259+ )
260+ )
261+ )
262+ }
263+ },
232264 onSelectedFilterChange = { filter ->
233265 viewModel.setFilter(filter)
234266 },
@@ -247,17 +279,17 @@ internal fun MapScreen(
247279 locationSource : FusedLocationSource ,
248280 departmentSheetState : SheetState ,
249281 partnershipSheetState : SheetState ,
250- showToast : (String ) -> Unit ,
282+ showToast : (UiText , ToastType ) -> Unit ,
251283 navigateToUserInfo : () -> Unit ,
252284 onHideDepartmentSheet : () -> Unit = {},
253285 onHidePartnershipSheet : () -> Unit = {},
286+ animateCameraPositionTo : (LatLng , Double ) -> Unit ,
254287 onSelectedFilterChange : (FilterType ) -> Unit ,
255288 departmentId : Long ,
256289 collegeId : Long ,
257290 departmentName : String? ,
258291 selectedFilter : FilterType ,
259292) {
260- val context = LocalContext .current
261293 Scaffold (
262294 topBar = {
263295 CenterAlignedTopAppBar (
@@ -331,46 +363,96 @@ internal fun MapScreen(
331363 locationSource = locationSource,
332364 contentPadding = PaddingValues (bottom = dimensionResource(R .dimen.bottom_nav_height)),
333365 properties = MapProperties (
334- locationTrackingMode = LocationTrackingMode .Follow ,
366+ // 현재 다른 위치에 있는 경우에도 숭실대입구를 보여주어야 함
367+ locationTrackingMode = LocationTrackingMode .NoFollow ,
335368 ),
336369 ) {
337- mapState.partnerships.forEach { partnership ->
338- val markerState = rememberMarkerState(
339- position = LatLng (
340- partnership.latitude,
341- partnership.longitude
342- )
370+ val clusterItems = mapState.partnerships.associateBy {
371+ ItemKey (
372+ it.storeName,
373+ LatLng (it.latitude, it.longitude)
343374 )
375+ }
344376
345- Marker (
346- icon = OverlayImage .fromResource(
347- when (partnership.restaurantType) {
377+ Clustering (
378+ items = clusterItems,
379+ thresholdStrategy = {
380+ // 줌 레벨에 상관 없이 임의의 값 사용
381+ 25.0
382+ },
383+
384+ clusterContent = {
385+ Box (
386+ modifier = Modifier
387+ .size(32 .dp)
388+ .background(Primary , CircleShape ),
389+ contentAlignment = Alignment .Center
390+ ) {
391+ Text (
392+ text = " ${it.size} " ,
393+ color = Color .White ,
394+ style = EatssuTheme .typography.body2
395+ )
396+ }
397+ },
398+ leafContent = { info ->
399+ val partnership = info.tag as ? Partnership ? : return @Clustering
400+
401+ Row (
402+ modifier = Modifier
403+ .background(Color .White , RoundedCornerShape (13 .dp))
404+ .border(1 .dp, Gray300 , RoundedCornerShape (13 .dp))
405+ .padding(
406+ start = 3 .dp,
407+ end = 7 .dp,
408+ top = 2.5 .dp,
409+ bottom = 2.5 .dp
410+ ),
411+ verticalAlignment = Alignment .CenterVertically ,
412+ horizontalArrangement = spacedBy(
413+ 3 .dp
414+ )
415+ ) {
416+ val iconRes = when (partnership.restaurantType) {
348417 RestaurantType .CAFE -> R .drawable.ic_map_marker_cafe
349- RestaurantType .RESTAURANT -> R .drawable.ic_map_marker_restaurant
350418 RestaurantType .PUB -> R .drawable.ic_map_marker_pub
419+ else -> R .drawable.ic_map_marker_restaurant
351420 }
352- ),
353- width = 20 .dp,
354- height = 20 .dp,
355- captionAligns = arrayOf(Align .Bottom ),
356- state = markerState,
357- captionText = partnership.storeName,
358- captionColor = Black ,
359- captionTextSize = 10 .sp,
360- onClick = {
361- if (partnership.partnershipInfos.isEmpty()) {
362- showToast(context.getString(R .string.toast_partnership_info_not_found))
363- true
364- } else {
365- // 제휴 정보가 있을 때만 바텀시트 띄움
366- // LaunchedEffect에서 자동으로 표시됨
367- viewModel.selectPartnershipByStoreName(partnership.storeName)
368- true
369- }
421+
422+ Image (
423+ painter = painterResource(id = iconRes),
424+ contentDescription = null ,
425+ modifier = Modifier .size(20 .dp)
426+ )
427+
428+ Text (
429+ text = partnership.storeName,
430+ style = EatssuTheme .typography.caption3,
431+ color = Color .Black
432+ )
370433 }
371- )
434+ },
435+ onClickCluster = { info, _ ->
436+ animateCameraPositionTo(info.position, cameraPositionState.position.zoom)
437+ true
438+ },
439+ onClickLeaf = { info, _ ->
440+ val partnership = info.tag as ? Partnership ? : return @Clustering true
441+
442+ if (partnership.partnershipInfos.isEmpty()) {
443+ // 제휴 정보가 없을 때는 토스트만 띄우고 바텀시트는 안 띄움
444+ showToast(
445+ UiText .StringResource (R .string.toast_partnership_info_not_found),
446+ ToastType .INFO
447+ )
448+ } else {
449+ // 제휴 정보가 있을 때만 바텀시트 띄움
450+ viewModel.selectPartnershipByStoreName(partnership.storeName)
451+ }
452+ true
453+ }
372454
373- }
455+ )
374456 }
375457
376458 // 학과 정보를 입력하지 않은 상태에서 제휴 필터를 변경하려고 할 때 BottomSheet 표시
@@ -384,25 +466,6 @@ internal fun MapScreen(
384466 modifier = Modifier .padding(top = 12 .dp),
385467 departmentName = departmentName.toString()
386468 )
387-
388- // 찜 기능
389- // FloatingActionButton(
390- // onClick = { /* TODO */ },
391- // containerColor = White,
392- // elevation = FloatingActionButtonDefaults.elevation(4.dp),
393- // shape = CircleShape,
394- // modifier = Modifier
395- // .padding(top = 12.dp, end = 16.dp)
396- // .border(width = 1.dp, color = Gray300, shape = CircleShape)
397- // .size(40.dp)
398- // .align(Alignment.TopEnd)
399- // ) {
400- // Image(
401- // painter = painterResource(id = R.drawable.ic_like),
402- // contentDescription = "좋아요",
403- // modifier = Modifier.size(20.dp)
404- // )
405- // }
406469 }
407470 }
408471}
@@ -421,3 +484,7 @@ fun MapFragmentComposeViewPreview() {
421484 MapRoute ()
422485 }
423486}
487+
488+ data class ItemKey (val id : String , private val latLng : LatLng ) : ClusteringKey {
489+ override fun getPosition () = latLng
490+ }
0 commit comments