Skip to content
Merged
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 @@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.eatssu.android.analytics.ProvideAnalyticsTracker
import com.eatssu.android.presentation.map.MapExternalNavigator
import com.eatssu.common.analytics.AnalyticsTracker
import com.eatssu.design_system.theme.EatssuTheme
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -19,6 +20,9 @@ class GoodPriceMapActivity : ComponentActivity() {
@Inject
lateinit var analyticsTracker: AnalyticsTracker

@Inject
lateinit var mapExternalNavigator: MapExternalNavigator

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Expand All @@ -27,7 +31,7 @@ class GoodPriceMapActivity : ComponentActivity() {
// 애널리틱스 트래커 및 EatssuTheme 적용
ProvideAnalyticsTracker(analyticsTracker) {
EatssuTheme {
GoodPriceMapRoute()
GoodPriceMapRoute(mapExternalNavigator = mapExternalNavigator)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,18 @@ import com.eatssu.android.R
import com.eatssu.android.domain.model.GoodPriceStore
import com.eatssu.android.presentation.goodprice.component.GoodPriceFilterRow
import com.eatssu.android.presentation.goodprice.component.GoodPriceStoreBottomSheet
import com.eatssu.android.presentation.map.MapDestination
import com.eatssu.android.presentation.map.MapExternalNavigator
import com.eatssu.android.presentation.map.MapProvider
import com.eatssu.android.presentation.map.findActivityOrNull
import com.eatssu.android.presentation.map.openMapWithoutResolution
import com.eatssu.android.presentation.util.TrackScreenViewEvent
import com.eatssu.android.presentation.util.showToast
import com.eatssu.common.UiEvent
import com.eatssu.common.UiText
import com.eatssu.common.enums.GoodPriceCategory
import com.eatssu.common.enums.ScreenId
import com.eatssu.common.enums.ToastType
import com.eatssu.design_system.component.EatSsuSnackbar
import com.eatssu.design_system.component.EatSsuSnackbarType
import com.eatssu.design_system.theme.Black
Expand Down Expand Up @@ -86,6 +93,7 @@ private const val LOCATION_PERMISSION_REQUEST_CODE = 2001
@Composable
fun GoodPriceMapRoute(
viewModel: GoodPriceMapViewModel = viewModel(),
mapExternalNavigator: MapExternalNavigator? = null,
showTopBar: Boolean = true,
contentPadding: PaddingValues = PaddingValues(),
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -133,7 +141,28 @@ fun GoodPriceMapRoute(
showTopBar = showTopBar,
contentPadding = contentPadding,
onCategorySelected = { viewModel.setCategory(it) },
onStoreClick = { viewModel.selectStore(it.id) },
onStoreClick = viewModel::selectStore,
onOpenMap = { provider, store ->
scope.launch {
val destination = MapDestination(
storeName = store.storeName,
latitude = store.latitude,
longitude = store.longitude,
)
val opened = mapExternalNavigator?.open(
context = context,
provider = provider,
destination = destination,
) ?: context.openMapWithoutResolution(provider, destination)

if (!opened) {
context.showToast(
UiText.StringResource(R.string.toast_map_open_failed),
ToastType.ERROR,
)
}
}
},
onDismissBottomSheet = { viewModel.clearSelectedStore() },
animateCameraTo = { latLng, zoom ->
scope.launch {
Expand All @@ -159,6 +188,7 @@ fun GoodPriceMapScreen(
contentPadding: PaddingValues = PaddingValues(),
onCategorySelected: (GoodPriceCategory) -> Unit,
onStoreClick: (GoodPriceStore) -> Unit,
onOpenMap: (MapProvider, GoodPriceStore) -> Unit,
onDismissBottomSheet: () -> Unit,
animateCameraTo: (LatLng, Double) -> Unit,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -333,8 +363,13 @@ fun GoodPriceMapScreen(

// 업소 상세 바텀시트
uiState.selectedStoreDetail?.let { detail ->
val selectedStore = uiState.selectedStore ?: return@let
if (selectedStore.id != detail.id) return@let

GoodPriceStoreBottomSheet(
storeDetail = detail,
onKakaoMapClick = { onOpenMap(MapProvider.KAKAO, selectedStore) },
onNaverMapClick = { onOpenMap(MapProvider.NAVER, selectedStore) },
onDismiss = onDismissBottomSheet,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.eatssu.common.enums.GoodPriceCategory
data class GoodPriceMapState(
val stores: List<GoodPriceStore> = emptyList(), // 지도에 표시할 업소 목록
val selectedCategory: GoodPriceCategory = GoodPriceCategory.ALL, // 현재 선택된 필터 카테고리
val selectedStore: GoodPriceStore? = null, // 딥링크에 사용할 선택 업소의 좌표 정보
val selectedStoreDetail: GoodPriceStoreDetail? = null, // 현재 선택되어 바텀시트에 표시할 업소 상세 정보
val isLoading: Boolean = false, // 로딩 상태
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.eatssu.android.R
import com.eatssu.android.data.model.ApiResult
import com.eatssu.android.domain.model.GoodPriceStore
import com.eatssu.android.domain.usecase.goodprice.GetGoodPriceStoreDetailUseCase
import com.eatssu.android.domain.usecase.goodprice.GetGoodPriceStoresUseCase
import com.eatssu.common.UiEvent
Expand Down Expand Up @@ -77,28 +78,48 @@ class GoodPriceMapViewModel @Inject constructor(
}

// 특정 업소 마커 클릭 시 상세 정보 조회
fun selectStore(storeId: Long) {
fun selectStore(store: GoodPriceStore) {
_uiState.update {
it.copy(
selectedStore = store,
selectedStoreDetail = null,
)
}

viewModelScope.launch {
when (val result = getGoodPriceStoreDetailUseCase(storeId)) {
when (val result = getGoodPriceStoreDetailUseCase(store.id)) {
is ApiResult.Success -> {
_uiState.update { it.copy(selectedStoreDetail = result.data) }
_uiState.update { state ->
if (state.selectedStore?.id == store.id) {
state.copy(selectedStoreDetail = result.data)
} else {
state
}
}
}

else -> {
// 상세 정보 조회 실패 시에도 동일한 에러 토스트 표시
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_load_good_price_stores_failed),
ToastType.ERROR,
if (_uiState.value.selectedStore?.id == store.id) {
// 상세 정보 조회 실패 시에도 동일한 에러 토스트 표시
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_load_good_price_stores_failed),
ToastType.ERROR,
)
)
)
}
}
}
}
}

// 바텀시트 닫을 때 선택된 업소 정보 초기화
fun clearSelectedStore() {
_uiState.update { it.copy(selectedStoreDetail = null) }
_uiState.update {
it.copy(
selectedStore = null,
selectedStoreDetail = null,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.eatssu.android.R
import com.eatssu.android.domain.model.GoodPriceStoreDetail
import com.eatssu.android.presentation.map.component.MapLinkButtons
import com.eatssu.common.enums.GoodPriceCategory
import com.eatssu.design_system.theme.Black
import com.eatssu.design_system.theme.EatssuTheme
Expand All @@ -43,6 +44,8 @@ import com.eatssu.design_system.theme.White
@Composable
fun GoodPriceStoreBottomSheet(
storeDetail: GoodPriceStoreDetail,
onKakaoMapClick: () -> Unit,
onNaverMapClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -137,5 +140,10 @@ fun GoodPriceStoreBottomSheet(
)
}
}

MapLinkButtons(
onKakaoMapClick = onKakaoMapClick,
onNaverMapClick = onNaverMapClick,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ package com.eatssu.android.presentation.map
import com.eatssu.android.BuildConfig
import com.eatssu.android.data.remote.dto.response.KakaoLocalSearchResponse
import com.eatssu.android.data.remote.service.KakaoLocalService
import com.eatssu.android.domain.model.PartnershipRestaurant
import timber.log.Timber
import javax.inject.Inject

class KakaoLocalPlaceResolver @Inject constructor(
private val kakaoLocalService: KakaoLocalService,
) {
internal suspend fun resolve(
restaurant: PartnershipRestaurant,
destination: MapDestination,
preferredPlaceId: String?,
): ResolvedMapPlace? {
val restApiKey = BuildConfig.KAKAO_REST_API_KEY.trim()
Expand All @@ -20,9 +19,9 @@ class KakaoLocalPlaceResolver @Inject constructor(
val response = runCatching {
kakaoLocalService.searchPlaces(
authorization = "KakaoAK $restApiKey",
query = restaurant.storeName,
longitude = restaurant.longitude,
latitude = restaurant.latitude,
query = destination.storeName,
longitude = destination.longitude,
latitude = destination.latitude,
)
}.onFailure { throwable ->
Timber.w(throwable, "Failed to resolve Kakao local place")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,35 @@ internal object MapDeepLink {
fun kakaoCoordinateUrl(latitude: Double, longitude: Double): String =
"kakaomap://look?p=$latitude,$longitude"

fun serverWebUrl(provider: MapProvider, restaurant: PartnershipRestaurant): String? =
fun serverWebUrl(provider: MapProvider, destination: MapDestination): String? =
when (provider) {
MapProvider.NAVER -> restaurant.naverMapUrl
MapProvider.KAKAO -> restaurant.kakaoMapUrl
MapProvider.NAVER -> destination.naverMapUrl
MapProvider.KAKAO -> destination.kakaoMapUrl
}?.takeIf { url -> url.isHttpWebUrl() }

fun serverWebUrl(provider: MapProvider, restaurant: PartnershipRestaurant): String? =
serverWebUrl(provider, restaurant.toMapDestination())

fun fallbackWebUrl(
provider: MapProvider,
restaurant: PartnershipRestaurant,
destination: MapDestination,
resolvedPlace: ResolvedMapPlace?,
): String = when (provider) {
MapProvider.NAVER ->
"https://map.naver.com/p/search/${(resolvedPlace?.name ?: restaurant.storeName).pathEncoded()}"
"https://map.naver.com/p/search/${(resolvedPlace?.name ?: destination.storeName).pathEncoded()}"

MapProvider.KAKAO -> resolvedPlace?.let { place ->
"https://map.kakao.com/link/map/${place.id}"
} ?: "https://map.kakao.com/link/map/" +
"${restaurant.storeName.pathEncoded()},${restaurant.latitude},${restaurant.longitude}"
"${destination.storeName.pathEncoded()},${destination.latitude},${destination.longitude}"
}

fun fallbackWebUrl(
provider: MapProvider,
restaurant: PartnershipRestaurant,
resolvedPlace: ResolvedMapPlace?,
): String = fallbackWebUrl(provider, restaurant.toMapDestination(), resolvedPlace)

fun supportsKakaoPlaceAction(versionName: String?): Boolean =
versionName
?.substringBefore('.')
Expand Down Expand Up @@ -106,36 +115,41 @@ internal object MapDeepLink {

internal fun Context.openMapWithoutResolution(
provider: MapProvider,
restaurant: PartnershipRestaurant,
destination: MapDestination,
): Boolean {
if (!isPackageInstalled(provider.packageName)) {
return openWebUrl(
MapDeepLink.serverWebUrl(provider, restaurant)
?: MapDeepLink.fallbackWebUrl(provider, restaurant, resolvedPlace = null),
MapDeepLink.serverWebUrl(provider, destination)
?: MapDeepLink.fallbackWebUrl(provider, destination, resolvedPlace = null),
)
}

val appUrl = when (provider) {
MapProvider.NAVER -> MapDeepLink.naverCoordinateUrl(
storeName = restaurant.storeName,
latitude = restaurant.latitude,
longitude = restaurant.longitude,
storeName = destination.storeName,
latitude = destination.latitude,
longitude = destination.longitude,
appName = packageName,
)

MapProvider.KAKAO -> MapDeepLink.kakaoCoordinateUrl(
latitude = restaurant.latitude,
longitude = restaurant.longitude,
latitude = destination.latitude,
longitude = destination.longitude,
)
}

return startMapUrl(appUrl, provider) ||
openWebUrl(
MapDeepLink.serverWebUrl(provider, restaurant)
?: MapDeepLink.fallbackWebUrl(provider, restaurant, resolvedPlace = null),
MapDeepLink.serverWebUrl(provider, destination)
?: MapDeepLink.fallbackWebUrl(provider, destination, resolvedPlace = null),
)
}

internal fun Context.openMapWithoutResolution(
provider: MapProvider,
restaurant: PartnershipRestaurant,
): Boolean = openMapWithoutResolution(provider, restaurant.toMapDestination())

internal fun Context.startMapUrl(url: String, provider: MapProvider): Boolean =
startMapIntent(mapIntent(url, provider.packageName), provider)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.eatssu.android.presentation.map

import com.eatssu.android.domain.model.PartnershipRestaurant

internal data class MapDestination(
val storeName: String,
val latitude: Double,
val longitude: Double,
val naverMapUrl: String? = null,
val kakaoMapUrl: String? = null,
)

internal fun PartnershipRestaurant.toMapDestination(): MapDestination = MapDestination(
storeName = storeName,
latitude = latitude,
longitude = longitude,
naverMapUrl = naverMapUrl,
kakaoMapUrl = kakaoMapUrl,
)
Loading
Loading