diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml
index 06a25cfa8..e203b41d1 100644
--- a/.github/workflows/debug.yml
+++ b/.github/workflows/debug.yml
@@ -52,6 +52,7 @@ jobs:
DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }}
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
NAVER_MAPS_CLIENT_ID: ${{ secrets.NAVER_MAPS_CLIENT_ID }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
@@ -59,6 +60,7 @@ jobs:
echo DEV_BASE_URL=\"$DEV_BASE_URL\" >> local.properties
echo PROD_BASE_URL=\"$PROD_BASE_URL\" >> local.properties
echo KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY >> local.properties
+ echo KAKAO_REST_API_KEY=$KAKAO_REST_API_KEY >> local.properties
echo NAVER_MAPS_CLIENT_ID=$NAVER_MAPS_CLIENT_ID >> local.properties
echo POSTHOG_API_KEY=$POSTHOG_API_KEY >> local.properties
echo POSTHOG_HOST=$POSTHOG_HOST >> local.properties
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d5b0c048b..4286feb66 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -111,6 +111,7 @@ jobs:
DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }}
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
NAVER_MAPS_CLIENT_ID: ${{ secrets.NAVER_MAPS_CLIENT_ID }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
@@ -123,6 +124,7 @@ jobs:
DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }}
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
NAVER_MAPS_CLIENT_ID: ${{ secrets.NAVER_MAPS_CLIENT_ID }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b19c2357a..5b994e86c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -82,6 +82,9 @@ android {
buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"")
manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoKey
+ val kakaoRestApiKey: String = p.getProperty("KAKAO_REST_API_KEY") ?: ""
+ buildConfigField("String", "KAKAO_REST_API_KEY", "\"$kakaoRestApiKey\"")
+
val naverMapsClientID: String = p.getProperty("NAVER_MAPS_CLIENT_ID")
buildConfigField("String", "NAVER_MAPS_CLIENT_ID", "\"$naverMapsClientID\"")
manifestPlaceholders["NAVER_MAPS_CLIENT_ID"] = naverMapsClientID
@@ -122,6 +125,9 @@ android {
buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"")
manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoKey
+ val kakaoRestApiKey: String = p.getProperty("KAKAO_REST_API_KEY") ?: ""
+ buildConfigField("String", "KAKAO_REST_API_KEY", "\"$kakaoRestApiKey\"")
+
val naverMapsClientID: String = p.getProperty("NAVER_MAPS_CLIENT_ID")
buildConfigField("String", "NAVER_MAPS_CLIENT_ID", "\"$naverMapsClientID\"")
manifestPlaceholders["NAVER_MAPS_CLIENT_ID"] = naverMapsClientID
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a8c4dd00a..361bd5a80 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,8 @@
+
+
diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/KakaoLocalSearchResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/KakaoLocalSearchResponse.kt
new file mode 100644
index 000000000..b5bfab258
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/KakaoLocalSearchResponse.kt
@@ -0,0 +1,22 @@
+package com.eatssu.android.data.remote.dto.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class KakaoLocalSearchResponse(
+ @SerialName("documents")
+ val documents: List = emptyList(),
+) {
+ @Serializable
+ data class Document(
+ @SerialName("id")
+ val id: String = "",
+ @SerialName("place_name")
+ val placeName: String = "",
+ @SerialName("distance")
+ val distance: String = "",
+ ) {
+ fun distanceInMeters(): Int = distance.toIntOrNull() ?: Int.MAX_VALUE
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/PartnershipResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/PartnershipResponse.kt
index 3d4cd0e80..5bc8166a9 100644
--- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/PartnershipResponse.kt
+++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/PartnershipResponse.kt
@@ -16,9 +16,13 @@ data class PartnershipResponse(
val latitude: Double? = null,
@SerialName("restaurantType")
val restaurantType: StoreType? = null,
+ @SerialName("naverMapUrl")
+ val naverMapUrl: String? = null,
+ @SerialName("kakaoMapUrl")
+ val kakaoMapUrl: String? = null,
@SerialName("partnershipInfos")
val partnershipInfos: List = emptyList()
-){
+) {
@Serializable
data class PartnershipInfo(
@SerialName("id")
@@ -50,6 +54,8 @@ fun PartnershipResponse.toDomain(): Partnership =
longitude = longitude ?: 126.95661313346206,
latitude = latitude ?: 37.49517278813046,
restaurantType = restaurantType ?: StoreType.RESTAURANT,
+ naverMapUrl = naverMapUrl?.trim()?.takeIf(String::isNotEmpty),
+ kakaoMapUrl = kakaoMapUrl?.trim()?.takeIf(String::isNotEmpty),
partnershipInfos = partnershipInfos.map {
Partnership.PartnershipInfo(
id = it.id ?: -1,
diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/KakaoLocalService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/KakaoLocalService.kt
new file mode 100644
index 000000000..04bae571e
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/service/KakaoLocalService.kt
@@ -0,0 +1,20 @@
+package com.eatssu.android.data.remote.service
+
+import com.eatssu.android.data.remote.dto.response.KakaoLocalSearchResponse
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.Query
+
+interface KakaoLocalService {
+ @GET("v2/local/search/keyword.json")
+ suspend fun searchPlaces(
+ @Header("Authorization") authorization: String,
+ @Query("query") query: String,
+ @Query("x") longitude: Double,
+ @Query("y") latitude: Double,
+ @Query("radius") radius: Int = 300,
+ @Query("size") size: Int = 5,
+ @Query("sort") sort: String = "distance",
+ ): Response
+}
diff --git a/app/src/main/java/com/eatssu/android/di/KakaoLocalModule.kt b/app/src/main/java/com/eatssu/android/di/KakaoLocalModule.kt
new file mode 100644
index 000000000..c05d3e0e9
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/di/KakaoLocalModule.kt
@@ -0,0 +1,27 @@
+package com.eatssu.android.di
+
+import javax.inject.Singleton
+import com.eatssu.android.data.remote.service.KakaoLocalService
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+
+@Module
+@InstallIn(SingletonComponent::class)
+object KakaoLocalModule {
+ @Provides
+ @Singleton
+ fun provideKakaoLocalService(json: Json): KakaoLocalService =
+ Retrofit.Builder()
+ .baseUrl("https://dapi.kakao.com/")
+ .client(OkHttpClient.Builder().build())
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+ .create(KakaoLocalService::class.java)
+}
diff --git a/app/src/main/java/com/eatssu/android/domain/model/Partnership.kt b/app/src/main/java/com/eatssu/android/domain/model/Partnership.kt
index 50c8833b1..2435d2ae3 100644
--- a/app/src/main/java/com/eatssu/android/domain/model/Partnership.kt
+++ b/app/src/main/java/com/eatssu/android/domain/model/Partnership.kt
@@ -8,7 +8,9 @@ data class Partnership(
val longitude: Double,
val latitude: Double,
val restaurantType: StoreType,
- val partnershipInfos: List
+ val partnershipInfos: List,
+ val naverMapUrl: String? = null,
+ val kakaoMapUrl: String? = null,
) {
data class PartnershipInfo(
val id: Int,
@@ -22,4 +24,4 @@ data class Partnership(
val endDate: String,
val periodType: PeriodType,
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt b/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt
index e642cb921..78036bc57 100644
--- a/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt
+++ b/app/src/main/java/com/eatssu/android/domain/model/PartnershipRestaurant.kt
@@ -16,4 +16,6 @@ data class PartnershipRestaurant(
val departmentName: String,
val partnershipLikeCount: Int,
val likedByUser: Boolean,
+ val naverMapUrl: String? = null,
+ val kakaoMapUrl: String? = null,
)
diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCase.kt
index bfda70a3d..d23273c79 100644
--- a/app/src/main/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCase.kt
+++ b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCase.kt
@@ -29,7 +29,9 @@ class GetPartnershipDetailUseCase @Inject constructor() {
collegeName = info.collegeName,
departmentName = info.departmentName,
partnershipLikeCount = info.likeCount,
- likedByUser = info.isLiked
+ likedByUser = info.isLiked,
+ naverMapUrl = matched.naverMapUrl,
+ kakaoMapUrl = matched.kakaoMapUrl,
)
}
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/KakaoLocalPlaceResolver.kt b/app/src/main/java/com/eatssu/android/presentation/map/KakaoLocalPlaceResolver.kt
new file mode 100644
index 000000000..ca7c0e36e
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/map/KakaoLocalPlaceResolver.kt
@@ -0,0 +1,61 @@
+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,
+ preferredPlaceId: String?,
+ ): ResolvedMapPlace? {
+ val restApiKey = BuildConfig.KAKAO_REST_API_KEY.trim()
+ if (restApiKey.isEmpty()) return null
+
+ val response = runCatching {
+ kakaoLocalService.searchPlaces(
+ authorization = "KakaoAK $restApiKey",
+ query = restaurant.storeName,
+ longitude = restaurant.longitude,
+ latitude = restaurant.latitude,
+ )
+ }.onFailure { throwable ->
+ Timber.w(throwable, "Failed to resolve Kakao local place")
+ }.getOrNull() ?: return null
+
+ if (!response.isSuccessful) {
+ Timber.w("Kakao local place search failed: %s", response.code())
+ return null
+ }
+
+ val matched = selectBestKakaoPlace(
+ documents = response.body()?.documents.orEmpty(),
+ preferredPlaceId = preferredPlaceId,
+ ) ?: return null
+
+ return ResolvedMapPlace(
+ id = matched.id,
+ name = matched.placeName,
+ )
+ }
+}
+
+internal fun selectBestKakaoPlace(
+ documents: List,
+ preferredPlaceId: String?,
+): KakaoLocalSearchResponse.Document? {
+ val matched = documents.firstOrNull { document -> document.id == preferredPlaceId }
+ ?: documents.minByOrNull(KakaoLocalSearchResponse.Document::distanceInMeters)
+ ?: return null
+
+ return matched.takeIf { document ->
+ document.distanceInMeters() <= MAX_MATCH_DISTANCE_METERS
+ }
+}
+
+private const val MAX_MATCH_DISTANCE_METERS = 300
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapDeepLink.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapDeepLink.kt
new file mode 100644
index 000000000..cbd975256
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapDeepLink.kt
@@ -0,0 +1,197 @@
+package com.eatssu.android.presentation.map
+
+import java.net.URI
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.net.toUri
+import com.eatssu.android.domain.model.PartnershipRestaurant
+import timber.log.Timber
+
+enum class MapProvider(
+ val packageName: String,
+) {
+ NAVER("com.nhn.android.nmap"),
+ KAKAO("net.daum.android.map"),
+}
+
+internal data class ResolvedMapPlace(
+ val id: String,
+ val name: String,
+)
+
+internal object MapDeepLink {
+ private const val KAKAO_PLACE_HOST = "place.map.kakao.com"
+
+ fun naverSearchUrl(storeName: String, appName: String): String =
+ "nmap://search?query=${storeName.urlEncoded()}&appname=${appName.urlEncoded()}"
+
+ fun naverCoordinateUrl(
+ storeName: String,
+ latitude: Double,
+ longitude: Double,
+ appName: String,
+ ): String = "nmap://place" +
+ "?lat=$latitude" +
+ "&lng=$longitude" +
+ "&name=${storeName.urlEncoded()}" +
+ "&appname=${appName.urlEncoded()}"
+
+ fun kakaoPlaceUrl(placeId: String): String = "kakaomap://place?id=$placeId"
+
+ fun kakaoSearchUrl(
+ storeName: String,
+ latitude: Double,
+ longitude: Double,
+ ): String = "kakaomap://search" +
+ "?q=${storeName.urlEncoded()}" +
+ "&p=$latitude,$longitude"
+
+ fun kakaoCoordinateUrl(latitude: Double, longitude: Double): String =
+ "kakaomap://look?p=$latitude,$longitude"
+
+ fun serverWebUrl(provider: MapProvider, restaurant: PartnershipRestaurant): String? =
+ when (provider) {
+ MapProvider.NAVER -> restaurant.naverMapUrl
+ MapProvider.KAKAO -> restaurant.kakaoMapUrl
+ }?.takeIf { url -> url.isHttpWebUrl() }
+
+ fun fallbackWebUrl(
+ provider: MapProvider,
+ restaurant: PartnershipRestaurant,
+ resolvedPlace: ResolvedMapPlace?,
+ ): String = when (provider) {
+ MapProvider.NAVER ->
+ "https://map.naver.com/p/search/${(resolvedPlace?.name ?: restaurant.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}"
+ }
+
+ fun supportsKakaoPlaceAction(versionName: String?): Boolean =
+ versionName
+ ?.substringBefore('.')
+ ?.toIntOrNull()
+ ?.let { majorVersion -> majorVersion >= 6 }
+ ?: false
+
+ internal fun kakaoPlaceId(webUrl: String): String? {
+ val uri = runCatching { URI(webUrl) }.getOrNull() ?: return null
+ if (uri.scheme !in setOf("http", "https")) return null
+ if (!uri.host.equals(KAKAO_PLACE_HOST, ignoreCase = true)) return null
+
+ return uri.path
+ ?.split('/')
+ ?.firstOrNull { it.isNotBlank() }
+ ?.takeIf { segment -> segment.all(Char::isDigit) }
+ }
+
+ private fun String.urlEncoded(): String =
+ URLEncoder.encode(this, StandardCharsets.UTF_8.name())
+
+ private fun String.pathEncoded(): String = urlEncoded().replace("+", "%20")
+
+ private fun String.isHttpWebUrl(): Boolean = runCatching {
+ val uri = URI(this)
+ uri.scheme in setOf("http", "https") && !uri.host.isNullOrBlank()
+ }.getOrDefault(false)
+}
+
+internal fun Context.openMapWithoutResolution(
+ provider: MapProvider,
+ restaurant: PartnershipRestaurant,
+): Boolean {
+ if (!isPackageInstalled(provider.packageName)) {
+ return openWebUrl(
+ MapDeepLink.serverWebUrl(provider, restaurant)
+ ?: MapDeepLink.fallbackWebUrl(provider, restaurant, resolvedPlace = null),
+ )
+ }
+
+ val appUrl = when (provider) {
+ MapProvider.NAVER -> MapDeepLink.naverCoordinateUrl(
+ storeName = restaurant.storeName,
+ latitude = restaurant.latitude,
+ longitude = restaurant.longitude,
+ appName = packageName,
+ )
+
+ MapProvider.KAKAO -> MapDeepLink.kakaoCoordinateUrl(
+ latitude = restaurant.latitude,
+ longitude = restaurant.longitude,
+ )
+ }
+
+ return startMapUrl(appUrl, provider) ||
+ openWebUrl(
+ MapDeepLink.serverWebUrl(provider, restaurant)
+ ?: MapDeepLink.fallbackWebUrl(provider, restaurant, resolvedPlace = null),
+ )
+}
+
+internal fun Context.startMapUrl(url: String, provider: MapProvider): Boolean =
+ startMapIntent(mapIntent(url, provider.packageName), provider)
+
+internal fun Context.startServerUrlInMapApp(
+ url: String?,
+ provider: MapProvider,
+): Boolean {
+ val webUrl = url ?: return false
+ val uri = webUrl.toUri()
+ if (uri.scheme !in setOf("http", "https")) return false
+
+ val intent = mapIntent(webUrl, provider.packageName)
+ if (intent.resolveActivity(packageManager) == null) return false
+
+ return startMapIntent(intent, provider)
+}
+
+internal fun Context.openWebUrl(url: String?): Boolean {
+ val webUrl = url ?: return false
+ val uri = webUrl.toUri()
+ if (uri.scheme !in setOf("http", "https")) return false
+
+ return startMapIntent(mapIntent(webUrl), provider = null)
+}
+
+internal fun Context.isPackageInstalled(packageName: String): Boolean =
+ packageInfo(packageName) != null
+
+internal fun Context.packageVersionName(packageName: String): String? =
+ packageInfo(packageName)?.versionName
+
+@Suppress("DEPRECATION")
+private fun Context.packageInfo(packageName: String): PackageInfo? =
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getPackageInfo(
+ packageName,
+ PackageManager.PackageInfoFlags.of(0),
+ )
+ } else {
+ packageManager.getPackageInfo(packageName, 0)
+ }
+ }.getOrNull()
+
+private fun Context.mapIntent(url: String, packageName: String? = null): Intent =
+ Intent(Intent.ACTION_VIEW, url.toUri()).apply {
+ addCategory(Intent.CATEGORY_BROWSABLE)
+ packageName?.let(::setPackage)
+ if (this@mapIntent !is Activity) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
+
+private fun Context.startMapIntent(intent: Intent, provider: MapProvider?): Boolean =
+ runCatching {
+ startActivity(intent)
+ }.onFailure { throwable ->
+ Timber.w(throwable, "Failed to open %s map intent", provider?.name ?: "web")
+ }.isSuccess
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapExternalNavigator.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapExternalNavigator.kt
new file mode 100644
index 000000000..0e2f1dbee
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapExternalNavigator.kt
@@ -0,0 +1,93 @@
+package com.eatssu.android.presentation.map
+
+import android.content.Context
+import com.eatssu.android.domain.model.PartnershipRestaurant
+import javax.inject.Inject
+
+class MapExternalNavigator @Inject constructor(
+ private val kakaoLocalPlaceResolver: KakaoLocalPlaceResolver,
+) {
+ suspend fun open(
+ context: Context,
+ provider: MapProvider,
+ restaurant: PartnershipRestaurant,
+ ): Boolean {
+ val serverWebUrl = MapDeepLink.serverWebUrl(provider, restaurant)
+ val serverKakaoPlaceId = restaurant.kakaoMapUrl
+ ?.let(MapDeepLink::kakaoPlaceId)
+ val isMapAppInstalled = context.isPackageInstalled(provider.packageName)
+
+ if (
+ provider == MapProvider.NAVER &&
+ !isMapAppInstalled &&
+ serverWebUrl != null
+ ) {
+ return context.openWebUrl(serverWebUrl)
+ }
+
+ if (
+ provider == MapProvider.NAVER &&
+ isMapAppInstalled &&
+ context.startServerUrlInMapApp(serverWebUrl, provider)
+ ) {
+ return true
+ }
+
+ val supportsKakaoPlaceAction = MapDeepLink.supportsKakaoPlaceAction(
+ context.packageVersionName(MapProvider.KAKAO.packageName),
+ )
+ val requiresExactPlaceName = provider == MapProvider.NAVER ||
+ serverKakaoPlaceId == null ||
+ !supportsKakaoPlaceAction
+ val resolvedPlace = if (requiresExactPlaceName) {
+ kakaoLocalPlaceResolver.resolve(
+ restaurant = restaurant,
+ preferredPlaceId = serverKakaoPlaceId,
+ )
+ } else {
+ null
+ }
+ val webFallbackUrl = serverWebUrl ?: MapDeepLink.fallbackWebUrl(
+ provider = provider,
+ restaurant = restaurant,
+ resolvedPlace = resolvedPlace,
+ )
+
+ if (!isMapAppInstalled) return context.openWebUrl(webFallbackUrl)
+
+ val appUrl = when (provider) {
+ MapProvider.NAVER -> resolvedPlace?.let { place ->
+ MapDeepLink.naverSearchUrl(
+ storeName = place.name,
+ appName = context.packageName,
+ )
+ } ?: MapDeepLink.naverCoordinateUrl(
+ storeName = restaurant.storeName,
+ latitude = restaurant.latitude,
+ longitude = restaurant.longitude,
+ appName = context.packageName,
+ )
+
+ MapProvider.KAKAO -> {
+ val placeId = serverKakaoPlaceId ?: resolvedPlace?.id
+ when {
+ supportsKakaoPlaceAction && placeId != null ->
+ MapDeepLink.kakaoPlaceUrl(placeId)
+
+ resolvedPlace != null -> MapDeepLink.kakaoSearchUrl(
+ storeName = resolvedPlace.name,
+ latitude = restaurant.latitude,
+ longitude = restaurant.longitude,
+ )
+
+ else -> MapDeepLink.kakaoCoordinateUrl(
+ latitude = restaurant.latitude,
+ longitude = restaurant.longitude,
+ )
+ }
+ }
+ }
+
+ return context.startMapUrl(appUrl, provider) || context.openWebUrl(webFallbackUrl)
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt
index 1ca359c27..e76e68dd4 100644
--- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt
@@ -18,6 +18,9 @@ class MapFragment : Fragment() {
@Inject
lateinit var analyticsTracker: AnalyticsTracker
+ @Inject
+ lateinit var mapExternalNavigator: MapExternalNavigator
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -32,7 +35,7 @@ class MapFragment : Fragment() {
setContent {
ProvideAnalyticsTracker(analyticsTracker) {
EatssuTheme {
- MapRoute()
+ MapRoute(mapExternalNavigator = mapExternalNavigator)
}
}
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
index b7f5d4840..b44376a1a 100644
--- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.eatssu.android.R
import com.eatssu.android.analytics.LocalAnalyticsTracker
import com.eatssu.android.domain.model.Partnership
+import com.eatssu.android.domain.model.PartnershipRestaurant
import com.eatssu.android.presentation.MainState
import com.eatssu.android.presentation.MainViewModel
import com.eatssu.android.presentation.map.component.DepartmentBottomSheet
@@ -98,7 +99,8 @@ private const val PERMISSION_REQUEST_CODE = 1001
@Composable
fun MapRoute(
viewModel: MapViewModel = viewModel(),
- mainViewModel: MainViewModel = viewModel()
+ mainViewModel: MainViewModel = viewModel(),
+ mapExternalNavigator: MapExternalNavigator? = null,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -237,6 +239,22 @@ fun MapRoute(
context.showToast(uiText, info)
}
},
+ openMap = { provider, restaurant ->
+ scope.launch {
+ val opened = mapExternalNavigator?.open(
+ context = context,
+ provider = provider,
+ restaurant = restaurant,
+ ) ?: context.openMapWithoutResolution(provider, restaurant)
+
+ if (!opened) {
+ context.showToast(
+ UiText.StringResource(R.string.toast_map_open_failed),
+ ToastType.ERROR,
+ )
+ }
+ }
+ },
navigateToUserInfo = {
val intent = Intent(context, UserInfoActivity::class.java)
context.startActivity(intent)
@@ -245,7 +263,10 @@ fun MapRoute(
scope.launch { departmentSheetState.hide() }
},
onHidePartnershipSheet = {
- scope.launch { partnershipSheetState.hide() }
+ scope.launch {
+ partnershipSheetState.hide()
+ viewModel.clearSelectedPartnership()
+ }
},
animateCameraPositionTo = { position, currentZoom ->
scope.launch {
@@ -278,6 +299,7 @@ internal fun MapScreen(
departmentSheetState: SheetState,
partnershipSheetState: SheetState,
showToast: (UiText, ToastType) -> Unit,
+ openMap: (MapProvider, PartnershipRestaurant) -> Unit,
navigateToUserInfo: () -> Unit,
onHideDepartmentSheet: () -> Unit = {},
onHidePartnershipSheet: () -> Unit = {},
@@ -341,6 +363,12 @@ internal fun MapScreen(
storeName = info.storeName,
storeType = storeType,
mapRestaurantList = mapState.restaurantInfoList,
+ onNaverMapClick = {
+ openMap(MapProvider.NAVER, info)
+ },
+ onKakaoMapClick = {
+ openMap(MapProvider.KAKAO, info)
+ },
onDismiss = {
onHidePartnershipSheet()
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt
index 7061b3aaf..be68e25d1 100644
--- a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt
@@ -273,6 +273,19 @@ class MapViewModel @Inject constructor(
),
)
}
+
+ fun clearSelectedPartnership() {
+ val current = uiState.value
+ if (current !is UiState.Success) return
+
+ _uiState.value = UiState.Success(
+ current.data.copy(
+ restaurantPartnershipInfo = null,
+ restaurantInfoList = emptyList(),
+ storeType = null,
+ ),
+ )
+ }
}
private fun List.hasFestivalPartnership(): Boolean =
diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt
index b0fff816a..1db64fc86 100644
--- a/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/map/component/MapRestaurantBottomSheet.kt
@@ -2,9 +2,11 @@ package com.eatssu.android.presentation.map.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -53,7 +55,9 @@ fun MapRestaurantBottomSheet(
storeName: String,
storeType: StoreType,
mapRestaurantList: List,
- onDismiss: () -> Unit = {}
+ onNaverMapClick: () -> Unit = {},
+ onKakaoMapClick: () -> Unit = {},
+ onDismiss: () -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -154,7 +158,11 @@ fun MapRestaurantBottomSheet(
Spacer(modifier = Modifier.height(20.dp))
// 제휴 리스트
- LazyColumn(modifier = Modifier.fillMaxWidth()) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f, fill = false),
+ ) {
itemsIndexed(mapRestaurantList) { index, item ->
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text(
@@ -207,10 +215,76 @@ fun MapRestaurantBottomSheet(
}
}
}
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Gray200),
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .padding(horizontal = 24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ MapLinkButton(
+ iconRes = R.drawable.ic_kakao_map,
+ labelRes = R.string.map_open_kakao,
+ onClick = onKakaoMapClick,
+ modifier = Modifier.weight(1f),
+ )
+
+ Box(
+ modifier = Modifier
+ .width(1.dp)
+ .height(24.dp)
+ .background(Gray400),
+ )
+
+ MapLinkButton(
+ iconRes = R.drawable.ic_naver_map,
+ labelRes = R.string.map_open_naver,
+ onClick = onNaverMapClick,
+ modifier = Modifier.weight(1f),
+ )
+ }
}
}
}
+@Composable
+private fun MapLinkButton(
+ iconRes: Int,
+ labelRes: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxHeight()
+ .clickable(onClick = onClick),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Image(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = stringResource(labelRes),
+ style = EatssuTheme.typography.body2,
+ color = Gray600,
+ )
+ }
+}
+
@Preview(showBackground = true)
@Composable
fun MapRestaurantBottomSheetPreview() {
@@ -226,7 +300,7 @@ fun MapRestaurantBottomSheetPreview() {
MapRestaurantBottomSheet(
storeName = "현선이네",
mapRestaurantList = dummyList,
- storeType = StoreType.RESTAURANT
+ storeType = StoreType.RESTAURANT,
)
}
}
diff --git a/app/src/main/res/drawable-nodpi/ic_kakao_map.png b/app/src/main/res/drawable-nodpi/ic_kakao_map.png
new file mode 100644
index 000000000..aebf59326
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_kakao_map.png differ
diff --git a/app/src/main/res/drawable-nodpi/ic_naver_map.png b/app/src/main/res/drawable-nodpi/ic_naver_map.png
new file mode 100644
index 000000000..814d0ece2
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_naver_map.png differ
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index f55f953ad..e7e5e2f2d 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -302,6 +302,9 @@
Enter your department
Enter your department\nand check your personalized partnerships!
College/Department information unavailable
+ KakaoMap
+ Naver Map
+ Unable to open the map. Please try again later.
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index ce66cdabe..c39ec1835 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -302,6 +302,9 @@
学科を入力
学科を入力して\n自分だけの提携特典を確認しましょう!
学部・学科情報を確認できません
+ カカオマップ
+ NAVERマップ
+ 地図を開けません。しばらくしてからもう一度お試しください。
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 7f37623da..8ad3f17b0 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -302,6 +302,9 @@
Nhập ngành học của bạn
Nhập ngành học của bạn\nvà xem các ưu đãi dành riêng cho bạn!
Không có thông tin khoa/ngành
+ KakaoMap
+ Bản đồ Naver
+ Không thể mở bản đồ. Vui lòng thử lại sau.
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cde3a1e5e..cb2769533 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -301,6 +301,9 @@
학과 입력하기
학과를 입력하고\n나만의 제휴를 확인해보세요!
단과대/학과 정보를 알 수 없음
+ 카카오맵
+ 네이버지도
+ 지도를 열 수 없어요. 잠시 후 다시 시도해 주세요.
diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt
index d9252c2df..6c349ba86 100644
--- a/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt
+++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt
@@ -47,6 +47,30 @@ class PartnershipResponseMapperBehaviorSpec : AppBehaviorSpec({
}
}
+ `when`("지도 URL이 존재하면") {
+ val result = PartnershipResponse(
+ naverMapUrl = " https://naver.me/test ",
+ kakaoMapUrl = "https://place.map.kakao.com/test",
+ ).toDomain()
+
+ then("공백을 제거해 도메인 모델로 전달한다") {
+ result.naverMapUrl shouldBe "https://naver.me/test"
+ result.kakaoMapUrl shouldBe "https://place.map.kakao.com/test"
+ }
+ }
+
+ `when`("지도 URL이 빈 문자열이면") {
+ val result = PartnershipResponse(
+ naverMapUrl = " ",
+ kakaoMapUrl = null,
+ ).toDomain()
+
+ then("노출할 수 없는 URL을 null로 매핑한다") {
+ result.naverMapUrl shouldBe null
+ result.kakaoMapUrl shouldBe null
+ }
+ }
+
`when`("필드가 null인 응답을 매핑하면") {
val result = PartnershipResponse(
storeName = null,
@@ -72,6 +96,8 @@ class PartnershipResponseMapperBehaviorSpec : AppBehaviorSpec({
result.storeName shouldBe ""
result.longitude shouldBe 126.95661313346206
result.latitude shouldBe 37.49517278813046
+ result.naverMapUrl shouldBe null
+ result.kakaoMapUrl shouldBe null
result.partnershipInfos.first().id shouldBe -1
result.partnershipInfos.first().partnershipType shouldBe ""
result.partnershipInfos.first().likeCount shouldBe 0
diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt
index 2691ca839..aac975571 100644
--- a/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt
+++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt
@@ -43,6 +43,8 @@ class GetPartnershipDetailUseCaseBehaviorSpec : AppBehaviorSpec({
latitude = 37.0,
restaurantType = StoreType.CAFE,
partnershipInfos = infos,
+ naverMapUrl = "https://naver.me/test",
+ kakaoMapUrl = "https://place.map.kakao.com/test",
)
)
@@ -59,6 +61,8 @@ class GetPartnershipDetailUseCaseBehaviorSpec : AppBehaviorSpec({
result?.storeName shouldBe "Cafe A"
result?.description shouldBe "음료 증정"
result?.storeType shouldBe StoreType.CAFE
+ result?.naverMapUrl shouldBe "https://naver.me/test"
+ result?.kakaoMapUrl shouldBe "https://place.map.kakao.com/test"
}
}
diff --git a/app/src/test/java/com/eatssu/android/presentation/map/KakaoLocalPlaceSelectorBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/KakaoLocalPlaceSelectorBehaviorSpec.kt
new file mode 100644
index 000000000..3ac990915
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/map/KakaoLocalPlaceSelectorBehaviorSpec.kt
@@ -0,0 +1,54 @@
+package com.eatssu.android.presentation.map
+
+import com.eatssu.android.data.remote.dto.response.KakaoLocalSearchResponse
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+
+class KakaoLocalPlaceSelectorBehaviorSpec : AppBehaviorSpec({
+
+ given("Kakao place candidates near a partnership") {
+ `when`("the server place ID exists in the search results") {
+ val serverPlace = document(id = "server", name = "에이블 PC방", distance = 65)
+ val nearestPlace = document(id = "nearest", name = "에이블 PC CAFE 숭실대점", distance = 8)
+
+ then("the server-provided place is selected first") {
+ selectBestKakaoPlace(
+ documents = listOf(serverPlace, nearestPlace),
+ preferredPlaceId = "server",
+ ) shouldBe serverPlace
+ }
+ }
+
+ `when`("the server place ID is absent from the search results") {
+ val fartherPlace = document(id = "farther", name = "놀숲 서울대점", distance = 120)
+ val nearestPlace = document(id = "nearest", name = "놀숲 숭실대점", distance = 15)
+
+ then("the nearest result is selected") {
+ selectBestKakaoPlace(
+ documents = listOf(fartherPlace, nearestPlace),
+ preferredPlaceId = "missing",
+ ) shouldBe nearestPlace
+ }
+ }
+
+ `when`("the selected result is outside the allowed radius") {
+ then("the resolver falls back to coordinates") {
+ selectBestKakaoPlace(
+ documents = listOf(document(id = "1", name = "놀숲 숭실대점", distance = 301)),
+ preferredPlaceId = "1",
+ ).shouldBeNull()
+ }
+ }
+ }
+})
+
+private fun document(
+ id: String,
+ name: String,
+ distance: Int,
+) = KakaoLocalSearchResponse.Document(
+ id = id,
+ placeName = name,
+ distance = distance.toString(),
+)
diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapDeepLinkBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapDeepLinkBehaviorSpec.kt
new file mode 100644
index 000000000..ea57f44ff
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/map/MapDeepLinkBehaviorSpec.kt
@@ -0,0 +1,79 @@
+package com.eatssu.android.presentation.map
+
+import com.eatssu.android.test.AppBehaviorSpec
+import io.kotest.matchers.shouldBe
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+class MapDeepLinkBehaviorSpec : AppBehaviorSpec({
+
+ given("네이버 지도 앱 딥링크") {
+ `when`("상호명과 앱 ID를 전달하면") {
+ val url = MapDeepLink.naverSearchUrl(
+ storeName = "현선이네 숭실대점",
+ appName = "com.eatssu.android.debug",
+ )
+
+ then("상호명이 인코딩된 검색 URL을 만든다") {
+ url.substringBefore('?') shouldBe "nmap://search"
+ url.queryParameter("query") shouldBe "현선이네 숭실대점"
+ url.queryParameter("appname") shouldBe "com.eatssu.android.debug"
+ }
+ }
+ }
+
+ given("카카오맵 앱 딥링크") {
+ `when`("서버 URL에 장소 ID가 있으면") {
+ then("해당 장소 상세 URL을 만든다") {
+ MapDeepLink.kakaoPlaceUrl("273098143") shouldBe
+ "kakaomap://place?id=273098143"
+ }
+ }
+
+ `when`("정확한 상호명을 조회했으면") {
+ then("상호명과 좌표를 포함한 검색 URL을 만든다") {
+ MapDeepLink.kakaoSearchUrl(
+ storeName = "크라이치즈버거 숭실대점",
+ latitude = 37.4987842,
+ longitude = 126.9520631,
+ ).queryParameter("q") shouldBe "크라이치즈버거 숭실대점"
+ }
+ }
+
+ `when`("장소 ID가 숫자가 아니면") {
+ then("장소 ID로 사용하지 않는다") {
+ MapDeepLink.kakaoPlaceId(
+ "https://place.map.kakao.com/not-a-place-id"
+ ) shouldBe null
+ }
+ }
+
+ `when`("카카오맵 버전이 6 이상이면") {
+ then("장소 ID 액션을 지원한다") {
+ MapDeepLink.supportsKakaoPlaceAction("6.0.0") shouldBe true
+ MapDeepLink.supportsKakaoPlaceAction("7.1.2") shouldBe true
+ }
+ }
+
+ `when`("카카오맵 버전이 6 미만이면") {
+ then("장소 ID 액션 대신 검색 폴백을 사용한다") {
+ MapDeepLink.supportsKakaoPlaceAction("1.12.1") shouldBe false
+ MapDeepLink.supportsKakaoPlaceAction(null) shouldBe false
+ }
+ }
+ }
+})
+
+private fun String.queryParameter(name: String): String? =
+ substringAfter('?', missingDelimiterValue = "")
+ .split('&')
+ .mapNotNull { pair ->
+ val separatorIndex = pair.indexOf('=')
+ if (separatorIndex < 0) return@mapNotNull null
+
+ val key = pair.substring(0, separatorIndex)
+ val value = pair.substring(separatorIndex + 1)
+ key to URLDecoder.decode(value, StandardCharsets.UTF_8.name())
+ }
+ .firstOrNull { (key, _) -> key == name }
+ ?.second
diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt
index ce3e92d2d..7110b153d 100644
--- a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt
+++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt
@@ -490,5 +490,51 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
}
}
}
+
+ `when`("a partnership sheet is dismissed and the same store is selected again") {
+ val partnerships = listOf(samplePartnership(storeName = "Nolsoop"))
+ val representative = samplePartnershipRestaurant(type = StoreType.CAFE)
+
+ coEvery {
+ getUserCollegeDepartmentUseCase()
+ } returns sampleUserInfo(
+ nickname = "eatssu",
+ college = College(collegeId = 1, collegeName = "IT"),
+ department = Department(departmentId = 11, departmentName = "CS"),
+ )
+ coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships
+ coEvery { partnershipRepository.getAllPartnerships() } returns emptyList()
+ every { getPartnershipDetailUseCase(partnerships, "Nolsoop", 1) } returns representative
+
+ val viewModel = MapViewModel(
+ partnershipRepository = partnershipRepository,
+ getPartnershipDetailUseCase = getPartnershipDetailUseCase,
+ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase,
+ analyticsTracker = analyticsTracker,
+ )
+
+ then("the selection state is cleared before reopening the same store") {
+ runTest {
+ eventually(2.seconds) {
+ (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships
+ }
+
+ viewModel.selectPartnershipByStoreName("Nolsoop")
+ (viewModel.uiState.value as UiState.Success)
+ .data.restaurantPartnershipInfo shouldBe representative
+
+ viewModel.clearSelectedPartnership()
+ with((viewModel.uiState.value as UiState.Success).data) {
+ restaurantPartnershipInfo shouldBe null
+ restaurantInfoList shouldBe emptyList()
+ storeType shouldBe null
+ }
+
+ viewModel.selectPartnershipByStoreName("Nolsoop")
+ (viewModel.uiState.value as UiState.Success)
+ .data.restaurantPartnershipInfo shouldBe representative
+ }
+ }
+ }
}
})
diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapWebFallbackBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapWebFallbackBehaviorSpec.kt
new file mode 100644
index 000000000..d1f45e18d
--- /dev/null
+++ b/app/src/test/java/com/eatssu/android/presentation/map/MapWebFallbackBehaviorSpec.kt
@@ -0,0 +1,53 @@
+package com.eatssu.android.presentation.map
+
+import com.eatssu.android.test.AppBehaviorSpec
+import com.eatssu.android.test.samplePartnershipRestaurant
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+
+class MapWebFallbackBehaviorSpec : AppBehaviorSpec({
+
+ given("a partnership without server map URLs") {
+ val restaurant = samplePartnershipRestaurant().copy(
+ storeName = "놀숲 숭실대점",
+ latitude = 37.495,
+ longitude = 126.957,
+ naverMapUrl = null,
+ kakaoMapUrl = null,
+ )
+
+ `when`("the Naver Map app is unavailable") {
+ then("a browser search URL is available") {
+ MapDeepLink.fallbackWebUrl(
+ provider = MapProvider.NAVER,
+ restaurant = restaurant,
+ resolvedPlace = null,
+ ) shouldContain "https://map.naver.com/p/search/"
+ }
+ }
+
+ `when`("the KakaoMap app is unavailable") {
+ then("a browser coordinate URL is available") {
+ MapDeepLink.fallbackWebUrl(
+ provider = MapProvider.KAKAO,
+ restaurant = restaurant,
+ resolvedPlace = null,
+ ) shouldBe "https://map.kakao.com/link/map/" +
+ "%EB%86%80%EC%88%B2%20%EC%88%AD%EC%8B%A4%EB%8C%80%EC%A0%90,37.495,126.957"
+ }
+ }
+ }
+
+ given("a malformed server URL") {
+ `when`("the URL is selected for navigation") {
+ then("it is rejected so a generated fallback can be used") {
+ MapDeepLink.serverWebUrl(
+ provider = MapProvider.NAVER,
+ restaurant = samplePartnershipRestaurant(
+ naverMapUrl = "not-a-web-url",
+ ),
+ ) shouldBe null
+ }
+ }
+ }
+})
diff --git a/app/src/test/java/com/eatssu/android/test/TestFixtures.kt b/app/src/test/java/com/eatssu/android/test/TestFixtures.kt
index 62aa2cef2..be292e4f2 100644
--- a/app/src/test/java/com/eatssu/android/test/TestFixtures.kt
+++ b/app/src/test/java/com/eatssu/android/test/TestFixtures.kt
@@ -73,6 +73,8 @@ fun sampleReviewInfo(
fun samplePartnership(
storeName: String = "Cafe A",
+ naverMapUrl: String? = null,
+ kakaoMapUrl: String? = null,
infos: List = listOf(
Partnership.PartnershipInfo(
id = 1,
@@ -94,11 +96,15 @@ fun samplePartnership(
latitude = 37.0,
restaurantType = type,
partnershipInfos = infos,
+ naverMapUrl = naverMapUrl,
+ kakaoMapUrl = kakaoMapUrl,
)
fun samplePartnershipRestaurant(
id: Int = 1,
type: StoreType = StoreType.CAFE,
+ naverMapUrl: String? = null,
+ kakaoMapUrl: String? = null,
) = PartnershipRestaurant(
id = id,
partnershipType = "DISCOUNT",
@@ -113,4 +119,6 @@ fun samplePartnershipRestaurant(
departmentName = "CS",
partnershipLikeCount = 3,
likedByUser = true,
+ naverMapUrl = naverMapUrl,
+ kakaoMapUrl = kakaoMapUrl,
)
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 3f76f6cf0..9170bba61 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -27,9 +27,14 @@ platform :android do
dev_base_url = ENV["DEV_BASE_URL"] || ""
prod_base_url = ENV["PROD_BASE_URL"] || ""
kakao_key = ENV["KAKAO_NATIVE_APP_KEY"] || ""
+ kakao_rest_api_key = ENV["KAKAO_REST_API_KEY"] || ""
naver_maps_id = ENV["NAVER_MAPS_CLIENT_ID"] || ""
posthog_api_key = ENV["POSTHOG_API_KEY"] || ""
posthog_host = ENV["POSTHOG_HOST"] || ""
+
+ if environment == "production" && kakao_rest_api_key.empty?
+ UI.user_error!("KAKAO_REST_API_KEY is required for a production build")
+ end
base_url = environment == "production" ? prod_base_url : dev_base_url
@@ -39,6 +44,7 @@ platform :android do
"DEV_BASE_URL=\"#{dev_base_url}\"",
"PROD_BASE_URL=\"#{prod_base_url}\"",
"KAKAO_NATIVE_APP_KEY=#{kakao_key}",
+ "KAKAO_REST_API_KEY=#{kakao_rest_api_key}",
"NAVER_MAPS_CLIENT_ID=#{naver_maps_id}",
"POSTHOG_API_KEY=#{posthog_api_key}",
"POSTHOG_HOST=#{posthog_host}"