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}"