diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 361bd5a80..6924cc83d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -157,6 +157,9 @@
+
diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreDetailResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreDetailResponse.kt
new file mode 100644
index 000000000..aaf52a6c2
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreDetailResponse.kt
@@ -0,0 +1,30 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.domain.model.GoodPriceStoreDetail
+import com.eatssu.common.enums.GoodPriceCategory
+import kotlinx.serialization.Serializable
+
+/**
+ * 착한가격업소 상세 조회 응답 DTO
+ */
+@Serializable
+data class GoodPriceStoreDetailResponse(
+ val id: Long,
+ val storeName: String,
+ val category: String,
+ val mainMenu: String? = null,
+ val price: Int? = null,
+ val roadAddress: String? = null,
+ val imageUrl: String? = null,
+)
+
+// DTO -> Domain 상세 모델 변환
+fun GoodPriceStoreDetailResponse.toDomain(): GoodPriceStoreDetail = GoodPriceStoreDetail(
+ id = id,
+ storeName = storeName,
+ category = GoodPriceCategory.fromServerKey(category),
+ mainMenu = mainMenu,
+ price = price,
+ roadAddress = roadAddress,
+ imageUrl = imageUrl,
+)
diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreResponse.kt
new file mode 100644
index 000000000..e73b76a5c
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/GoodPriceStoreResponse.kt
@@ -0,0 +1,26 @@
+package com.eatssu.android.data.remote.dto.response
+
+import com.eatssu.android.domain.model.GoodPriceStore
+import com.eatssu.common.enums.GoodPriceCategory
+import kotlinx.serialization.Serializable
+
+/**
+ * 착한가격업소 목록 조회 응답 DTO
+ */
+@Serializable
+data class GoodPriceStoreResponse(
+ val id: Long,
+ val storeName: String,
+ val category: String,
+ val latitude: Double,
+ val longitude: Double,
+)
+
+// DTO -> Domain 모델 변환
+fun GoodPriceStoreResponse.toDomain(): GoodPriceStore = GoodPriceStore(
+ id = id,
+ storeName = storeName,
+ category = GoodPriceCategory.fromServerKey(category),
+ latitude = latitude,
+ longitude = longitude,
+)
diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/GoodPriceStoreRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/GoodPriceStoreRepositoryImpl.kt
new file mode 100644
index 000000000..994cde592
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/repository/GoodPriceStoreRepositoryImpl.kt
@@ -0,0 +1,32 @@
+package com.eatssu.android.data.remote.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.model.map
+import com.eatssu.android.data.remote.dto.response.toDomain
+import com.eatssu.android.data.remote.service.GoodPriceStoreService
+import com.eatssu.android.domain.model.GoodPriceStore
+import com.eatssu.android.domain.model.GoodPriceStoreDetail
+import com.eatssu.android.domain.repository.GoodPriceStoreRepository
+import com.eatssu.common.enums.GoodPriceCategory
+import javax.inject.Inject
+
+/**
+ * 착한가격업소 저장소 구현체
+ */
+class GoodPriceStoreRepositoryImpl @Inject constructor(
+ private val service: GoodPriceStoreService,
+) : GoodPriceStoreRepository {
+
+ // 필터 카테고리에 맞는 업소 목록 조회 (ALL이거나 null이면 전체 조회)
+ override suspend fun getStores(category: GoodPriceCategory?): ApiResult> {
+ val serverKey = if (category == GoodPriceCategory.ALL) null else category?.serverKey
+ return service.getStores(category = serverKey).map { list ->
+ list.map { it.toDomain() }
+ }
+ }
+
+ // 업소 상세 정보 조회
+ override suspend fun getStoreDetail(id: Long): ApiResult {
+ return service.getStoreDetail(id).map { it.toDomain() }
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/GoodPriceStoreService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/GoodPriceStoreService.kt
new file mode 100644
index 000000000..9a63c599b
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/data/remote/service/GoodPriceStoreService.kt
@@ -0,0 +1,26 @@
+package com.eatssu.android.data.remote.service
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.data.remote.dto.response.GoodPriceStoreDetailResponse
+import com.eatssu.android.data.remote.dto.response.GoodPriceStoreResponse
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+/**
+ * 착한가격업소 관련 API Retrofit Service (비로그인 사용자도 접근 가능)
+ */
+interface GoodPriceStoreService {
+
+ // 업종별 착한가격업소 목록 조회 (category 미지정 시 전체 조회)
+ @GET("good-price-stores")
+ suspend fun getStores(
+ @Query("category") category: String? = null,
+ ): ApiResult>
+
+ // 착한가격업소 단일 상세 정보 조회
+ @GET("good-price-stores/{id}")
+ suspend fun getStoreDetail(
+ @Path("id") id: Long,
+ ): ApiResult
+}
diff --git a/app/src/main/java/com/eatssu/android/di/DataModule.kt b/app/src/main/java/com/eatssu/android/di/DataModule.kt
index 618645a84..c70584133 100644
--- a/app/src/main/java/com/eatssu/android/di/DataModule.kt
+++ b/app/src/main/java/com/eatssu/android/di/DataModule.kt
@@ -79,4 +79,10 @@ abstract class DataModule {
internal abstract fun bindsPublicHolidayRepository(
publicHolidayRepositoryImpl: PublicHolidayRepositoryImpl,
): PublicHolidayRepository
+
+ // 착한가격업소 레포지토리 바인딩
+ @Binds
+ internal abstract fun bindsGoodPriceStoreRepository(
+ goodPriceStoreRepositoryImpl: com.eatssu.android.data.remote.repository.GoodPriceStoreRepositoryImpl,
+ ): com.eatssu.android.domain.repository.GoodPriceStoreRepository
}
diff --git a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt
index 7e3ff57b2..93105ee13 100644
--- a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt
+++ b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt
@@ -67,4 +67,10 @@ object ServiceModule {
return noTokenRetrofit.create(HealthCheckService::class.java)
}
+ // 착한가격업소 서비스 제공 (비로그인 사용자도 호출 가능하도록 @NoToken Retrofit 사용)
+ @Provides
+ @Singleton
+ fun provideGoodPriceStoreService(@NoToken noTokenRetrofit: Retrofit): com.eatssu.android.data.remote.service.GoodPriceStoreService {
+ return noTokenRetrofit.create(com.eatssu.android.data.remote.service.GoodPriceStoreService::class.java)
+ }
}
diff --git a/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStore.kt b/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStore.kt
new file mode 100644
index 000000000..5431831b0
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStore.kt
@@ -0,0 +1,14 @@
+package com.eatssu.android.domain.model
+
+import com.eatssu.common.enums.GoodPriceCategory
+
+/**
+ * 착한가격업소 지도 핀 표시용 도메인 모델
+ */
+data class GoodPriceStore(
+ val id: Long,
+ val storeName: String,
+ val category: GoodPriceCategory,
+ val latitude: Double,
+ val longitude: Double,
+)
diff --git a/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStoreDetail.kt b/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStoreDetail.kt
new file mode 100644
index 000000000..8c435c018
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/domain/model/GoodPriceStoreDetail.kt
@@ -0,0 +1,16 @@
+package com.eatssu.android.domain.model
+
+import com.eatssu.common.enums.GoodPriceCategory
+
+/**
+ * 착한가격업소 상세 정보 도메인 모델 (바텀시트 노출용)
+ */
+data class GoodPriceStoreDetail(
+ val id: Long,
+ val storeName: String,
+ val category: GoodPriceCategory,
+ val mainMenu: String?,
+ val price: Int?,
+ val roadAddress: String?,
+ val imageUrl: String?,
+)
diff --git a/app/src/main/java/com/eatssu/android/domain/repository/GoodPriceStoreRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/GoodPriceStoreRepository.kt
new file mode 100644
index 000000000..d63de86fa
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/domain/repository/GoodPriceStoreRepository.kt
@@ -0,0 +1,18 @@
+package com.eatssu.android.domain.repository
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.domain.model.GoodPriceStore
+import com.eatssu.android.domain.model.GoodPriceStoreDetail
+import com.eatssu.common.enums.GoodPriceCategory
+
+/**
+ * 착한가격업소 저장소 인터페이스
+ */
+interface GoodPriceStoreRepository {
+
+ // 업종별 착한가격업소 목록 조회
+ suspend fun getStores(category: GoodPriceCategory?): ApiResult>
+
+ // 착한가격업소 상세 정보 조회
+ suspend fun getStoreDetail(id: Long): ApiResult
+}
diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoreDetailUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoreDetailUseCase.kt
new file mode 100644
index 000000000..0f356394e
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoreDetailUseCase.kt
@@ -0,0 +1,17 @@
+package com.eatssu.android.domain.usecase.goodprice
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.domain.model.GoodPriceStoreDetail
+import com.eatssu.android.domain.repository.GoodPriceStoreRepository
+import javax.inject.Inject
+
+/**
+ * 착한가격업소 상세 정보 조회 유스케이스
+ */
+class GetGoodPriceStoreDetailUseCase @Inject constructor(
+ private val repository: GoodPriceStoreRepository,
+) {
+ suspend operator fun invoke(id: Long): ApiResult {
+ return repository.getStoreDetail(id)
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoresUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoresUseCase.kt
new file mode 100644
index 000000000..9b62a8c37
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/domain/usecase/goodprice/GetGoodPriceStoresUseCase.kt
@@ -0,0 +1,18 @@
+package com.eatssu.android.domain.usecase.goodprice
+
+import com.eatssu.android.data.model.ApiResult
+import com.eatssu.android.domain.model.GoodPriceStore
+import com.eatssu.android.domain.repository.GoodPriceStoreRepository
+import com.eatssu.common.enums.GoodPriceCategory
+import javax.inject.Inject
+
+/**
+ * 카테고리별 착한가격업소 목록 조회 유스케이스
+ */
+class GetGoodPriceStoresUseCase @Inject constructor(
+ private val repository: GoodPriceStoreRepository,
+) {
+ suspend operator fun invoke(category: GoodPriceCategory? = GoodPriceCategory.ALL): ApiResult> {
+ return repository.getStores(category)
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapActivity.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapActivity.kt
new file mode 100644
index 000000000..5e865826d
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapActivity.kt
@@ -0,0 +1,35 @@
+package com.eatssu.android.presentation.goodprice
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.eatssu.android.analytics.ProvideAnalyticsTracker
+import com.eatssu.common.analytics.AnalyticsTracker
+import com.eatssu.design_system.theme.EatssuTheme
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * 착한가격업소 지도 액티비티 (비로그인 사용자도 진입 가능)
+ */
+@AndroidEntryPoint
+class GoodPriceMapActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var analyticsTracker: AnalyticsTracker
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ // 애널리틱스 트래커 및 EatssuTheme 적용
+ ProvideAnalyticsTracker(analyticsTracker) {
+ EatssuTheme {
+ GoodPriceMapRoute()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapScreen.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapScreen.kt
new file mode 100644
index 000000000..276ad5432
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapScreen.kt
@@ -0,0 +1,351 @@
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalNaverMapApi::class)
+
+package com.eatssu.android.presentation.goodprice
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+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.findActivityOrNull
+import com.eatssu.android.presentation.util.TrackScreenViewEvent
+import com.eatssu.common.UiEvent
+import com.eatssu.common.enums.GoodPriceCategory
+import com.eatssu.common.enums.ScreenId
+import com.eatssu.design_system.component.EatSsuSnackbar
+import com.eatssu.design_system.component.EatSsuSnackbarType
+import com.eatssu.design_system.theme.Black
+import com.eatssu.design_system.theme.EatssuTheme
+import com.eatssu.design_system.theme.Primary
+import com.eatssu.design_system.theme.White
+import com.naver.maps.geometry.LatLng
+import com.naver.maps.map.CameraPosition
+import com.naver.maps.map.CameraUpdate
+import com.naver.maps.map.clustering.ClusteringKey
+import com.naver.maps.map.compose.CameraPositionState
+import com.naver.maps.map.compose.Clustering
+import com.naver.maps.map.compose.ExperimentalNaverMapApi
+import com.naver.maps.map.compose.LocationTrackingMode
+import com.naver.maps.map.compose.MapProperties
+import com.naver.maps.map.compose.MapUiSettings
+import com.naver.maps.map.compose.NaverMap
+import com.naver.maps.map.compose.rememberCameraPositionState
+import com.naver.maps.map.util.FusedLocationSource
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+// 서울 중심 좌표 기본값 (서울시 25개 자치구 착한가격업소 전체 조망용)
+private const val SEOUL_CENTER_LATITUDE = 37.5518
+private const val SEOUL_CENTER_LONGITUDE = 126.9882
+private const val DEFAULT_ZOOM = 11.5
+private const val LOCATION_PERMISSION_REQUEST_CODE = 2001
+
+/**
+ * 착한가격업소 지도 화면 엔트리포인트 (재사용 가능한 Route 컴포저블)
+ */
+@Composable
+fun GoodPriceMapRoute(
+ viewModel: GoodPriceMapViewModel = viewModel(),
+ showTopBar: Boolean = true,
+ contentPadding: PaddingValues = PaddingValues(),
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val activity = remember(context) { context.findActivityOrNull() }
+ val scope = rememberCoroutineScope()
+ var errorMessage by remember { mutableStateOf(null) }
+
+ // 카메라 위치 상태 설정
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition(
+ LatLng(SEOUL_CENTER_LATITUDE, SEOUL_CENTER_LONGITUDE),
+ DEFAULT_ZOOM
+ )
+ }
+
+ // 위치 추적 source 생성
+ val locationSource = remember(activity) {
+ activity?.let { FusedLocationSource(it, LOCATION_PERMISSION_REQUEST_CODE) }
+ }
+
+ // 에러 이벤트 수신 시 디자인 스낵바 메시지 노출
+ LaunchedEffect(Unit) {
+ viewModel.uiEvent.collectLatest { event ->
+ when (event) {
+ is UiEvent.ShowToast -> {
+ errorMessage = event.message.asString(context)
+ delay(3000)
+ errorMessage = null
+ }
+ }
+ }
+ }
+
+ // 화면 진입 로깅
+ TrackScreenViewEvent(ScreenId.GOOD_PRICE_MAP)
+
+ GoodPriceMapScreen(
+ uiState = uiState,
+ cameraPositionState = cameraPositionState,
+ locationSource = locationSource,
+ errorMessage = errorMessage,
+ showTopBar = showTopBar,
+ contentPadding = contentPadding,
+ onCategorySelected = { viewModel.setCategory(it) },
+ onStoreClick = { viewModel.selectStore(it.id) },
+ onDismissBottomSheet = { viewModel.clearSelectedStore() },
+ animateCameraTo = { latLng, zoom ->
+ scope.launch {
+ cameraPositionState.animate(
+ CameraUpdate.toCameraPosition(CameraPosition(latLng, zoom + 2.0))
+ )
+ }
+ },
+ modifier = modifier,
+ )
+}
+
+/**
+ * 착한가격업소 지도 UI 컴포넌트
+ */
+@Composable
+fun GoodPriceMapScreen(
+ uiState: GoodPriceMapState,
+ cameraPositionState: CameraPositionState,
+ locationSource: FusedLocationSource?,
+ errorMessage: String?,
+ showTopBar: Boolean = true,
+ contentPadding: PaddingValues = PaddingValues(),
+ onCategorySelected: (GoodPriceCategory) -> Unit,
+ onStoreClick: (GoodPriceStore) -> Unit,
+ onDismissBottomSheet: () -> Unit,
+ animateCameraTo: (LatLng, Double) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // [성능 최적화 1] 1,500개 아이템 매핑 객체 재생성 및 GC 방지를 위한 remember 캐싱
+ val clusterItems = remember(uiState.stores) {
+ uiState.stores.associateBy {
+ GoodPriceClusterKey(
+ id = it.id,
+ latLng = LatLng(it.latitude, it.longitude),
+ )
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ if (showTopBar) {
+ // 상단 타이틀: 디자인 가이드에 따라 뒤로가기 버튼 없이 가운데 타이틀만 표시
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.title_good_price_map),
+ style = EatssuTheme.typography.subtitle1,
+ color = Black,
+ )
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = White,
+ ),
+ )
+ }
+ },
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ // 네이버 지도 영역 (학교 제휴 탭과 동일하게 contentPadding 적용)
+ NaverMap(
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ uiSettings = MapUiSettings(
+ isZoomControlEnabled = false,
+ isLocationButtonEnabled = locationSource != null,
+ ),
+ locationSource = locationSource,
+ contentPadding = contentPadding,
+ properties = MapProperties(
+ locationTrackingMode = LocationTrackingMode.NoFollow,
+ ),
+ ) {
+ // 마커 클러스터링 적용
+ Clustering(
+ items = clusterItems,
+ // [성능 최적화 2] 줌 레벨에 따른 동적 클러스터링 반경 설정 (저배율에서 노드 개수 대폭 감소)
+ thresholdStrategy = {
+ val currentZoom = cameraPositionState.position.zoom
+ when {
+ currentZoom < 11.0 -> 70.0 // 서울 전체: 넓게 묶어 노드 수 최소화
+ currentZoom < 13.0 -> 50.0 // 구 단위
+ currentZoom < 15.0 -> 35.0 // 동 단위
+ else -> 20.0 // 상세 확대
+ }
+ },
+ // 클러스터 뱃지 디자인 (원형 민트색)
+ clusterContent = { cluster ->
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .background(Primary, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "${cluster.size}",
+ color = White,
+ style = EatssuTheme.typography.body2,
+ )
+ }
+ },
+ // 개별 마커 디자인 (원형 마커 아이콘 + 줌 레벨에 따른 텍스트 노출)
+ leafContent = { leaf ->
+ val store = leaf.tag as? GoodPriceStore ?: return@Clustering
+
+ // 마커 아이콘 분기:
+ // 1. 전체, 한식, 일식, 양식, 중식: 수저 아이콘 통일 (ic_map_marker_restaurant)
+ // 2. 베이커리: 새로 제작한 빵 아이콘 (ic_map_marker_bakery)
+ // 3. 기타: 기존 카페 아이콘 사용 (ic_map_marker_cafe)
+ val iconRes = when (store.category) {
+ GoodPriceCategory.BAKERY -> R.drawable.ic_map_marker_bakery
+ GoodPriceCategory.ETC -> R.drawable.ic_map_marker_cafe
+ else -> R.drawable.ic_map_marker_restaurant
+ }
+
+ val currentZoom = cameraPositionState.position.zoom
+ // [성능 최적화 3] 저배율(줌 < 12.5)에서는 텍스트 렌더링 비용을 절감하고 아이콘만 가볍게 노출
+ val showTitle = currentZoom >= 12.5
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(26.dp),
+ )
+ if (showTitle) {
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = store.storeName,
+ style = EatssuTheme.typography.caption3.copy(fontWeight = FontWeight.Bold),
+ color = Black,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ },
+ // 클러스터 클릭 시 부드럽게 확대
+ onClickCluster = { cluster, _ ->
+ animateCameraTo(cluster.position, cameraPositionState.position.zoom)
+ true
+ },
+ // 개별 마커 클릭 시 업소 상세 바텀시트 호출
+ onClickLeaf = { leaf, _ ->
+ val store = leaf.tag as? GoodPriceStore ?: return@Clustering true
+ onStoreClick(store)
+ true
+ },
+ )
+ }
+
+ // 상단 카테고리 필터 바 (지도 상단에 가로 스크롤 형태로 오버레이)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter)
+ .padding(top = 12.dp),
+ ) {
+ GoodPriceFilterRow(
+ selectedCategory = uiState.selectedCategory,
+ onCategorySelected = onCategorySelected,
+ )
+ }
+
+ // 로딩 인디케이터
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = Primary,
+ )
+ }
+
+ // 하단 에러 스낵바 (업소 정보/장소 불러오기 실패 시 노출)
+ AnimatedVisibility(
+ visible = errorMessage != null,
+ enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
+ exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(
+ bottom = 24.dp + contentPadding.calculateBottomPadding()
+ ),
+ ) {
+ errorMessage?.let { message ->
+ EatSsuSnackbar(
+ message = message,
+ type = EatSsuSnackbarType.Danger,
+ )
+ }
+ }
+
+ // 업소 상세 바텀시트
+ uiState.selectedStoreDetail?.let { detail ->
+ GoodPriceStoreBottomSheet(
+ storeDetail = detail,
+ onDismiss = onDismissBottomSheet,
+ )
+ }
+ }
+ }
+}
+
+// 클러스터링 키 클래스
+private data class GoodPriceClusterKey(
+ val id: Long,
+ private val latLng: LatLng,
+) : ClusteringKey {
+ override fun getPosition() = latLng
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapState.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapState.kt
new file mode 100644
index 000000000..1127a1d3b
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapState.kt
@@ -0,0 +1,15 @@
+package com.eatssu.android.presentation.goodprice
+
+import com.eatssu.android.domain.model.GoodPriceStore
+import com.eatssu.android.domain.model.GoodPriceStoreDetail
+import com.eatssu.common.enums.GoodPriceCategory
+
+/**
+ * 착한가격업소 지도 화면 상태
+ */
+data class GoodPriceMapState(
+ val stores: List = emptyList(), // 지도에 표시할 업소 목록
+ val selectedCategory: GoodPriceCategory = GoodPriceCategory.ALL, // 현재 선택된 필터 카테고리
+ val selectedStoreDetail: GoodPriceStoreDetail? = null, // 현재 선택되어 바텀시트에 표시할 업소 상세 정보
+ val isLoading: Boolean = false, // 로딩 상태
+)
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapViewModel.kt
new file mode 100644
index 000000000..5a0d3fafb
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/GoodPriceMapViewModel.kt
@@ -0,0 +1,104 @@
+package com.eatssu.android.presentation.goodprice
+
+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.usecase.goodprice.GetGoodPriceStoreDetailUseCase
+import com.eatssu.android.domain.usecase.goodprice.GetGoodPriceStoresUseCase
+import com.eatssu.common.UiEvent
+import com.eatssu.common.UiText
+import com.eatssu.common.enums.GoodPriceCategory
+import com.eatssu.common.enums.ToastType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * 착한가격업소 지도 ViewModel
+ */
+@HiltViewModel
+class GoodPriceMapViewModel @Inject constructor(
+ private val getGoodPriceStoresUseCase: GetGoodPriceStoresUseCase,
+ private val getGoodPriceStoreDetailUseCase: GetGoodPriceStoreDetailUseCase,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(GoodPriceMapState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _uiEvent = MutableSharedFlow()
+ val uiEvent: SharedFlow = _uiEvent.asSharedFlow()
+
+ init {
+ // 초기 진입 시 전체 업소 목록 조회
+ loadStores(GoodPriceCategory.ALL)
+ }
+
+ // 카테고리 필터 변경 (중복 선택 불가, 단일 선택)
+ fun setCategory(category: GoodPriceCategory) {
+ if (_uiState.value.selectedCategory == category) return
+ _uiState.update { it.copy(selectedCategory = category) }
+ loadStores(category)
+ }
+
+ // 업소 목록 API 호출
+ private fun loadStores(category: GoodPriceCategory) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ when (val result = getGoodPriceStoresUseCase(category)) {
+ is ApiResult.Success -> {
+ _uiState.update {
+ it.copy(
+ stores = result.data,
+ isLoading = false,
+ )
+ }
+ }
+
+ else -> {
+ _uiState.update { it.copy(isLoading = false) }
+ // 업소 정보를 못 불러온 경우 토스트 알림
+ _uiEvent.emit(
+ UiEvent.ShowToast(
+ UiText.StringResource(R.string.toast_load_good_price_stores_failed),
+ ToastType.ERROR,
+ )
+ )
+ }
+ }
+ }
+ }
+
+ // 특정 업소 마커 클릭 시 상세 정보 조회
+ fun selectStore(storeId: Long) {
+ viewModelScope.launch {
+ when (val result = getGoodPriceStoreDetailUseCase(storeId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(selectedStoreDetail = result.data) }
+ }
+
+ else -> {
+ // 상세 정보 조회 실패 시에도 동일한 에러 토스트 표시
+ _uiEvent.emit(
+ UiEvent.ShowToast(
+ UiText.StringResource(R.string.toast_load_good_price_stores_failed),
+ ToastType.ERROR,
+ )
+ )
+ }
+ }
+ }
+ }
+
+ // 바텀시트 닫을 때 선택된 업소 정보 초기화
+ fun clearSelectedStore() {
+ _uiState.update { it.copy(selectedStoreDetail = null) }
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceFilterRow.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceFilterRow.kt
new file mode 100644
index 000000000..e4bdb1e53
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceFilterRow.kt
@@ -0,0 +1,67 @@
+package com.eatssu.android.presentation.goodprice.component
+
+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.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.eatssu.common.enums.GoodPriceCategory
+import com.eatssu.design_system.theme.EatssuTheme
+import com.eatssu.design_system.theme.Primary
+import com.eatssu.design_system.theme.White
+
+/**
+ * 착한가격업소 카테고리 필터 칩 목록 (가로 스크롤 가능, 단일 선택)
+ * 명세 순서: 전체 / 한식 / 일식 / 양식 / 중식 / 베이커리 / 기타 순
+ * 글꼴: EatssuTypography Body3, 볼드 처리는 선택된 경우에만 적용
+ */
+@Composable
+fun GoodPriceFilterRow(
+ selectedCategory: GoodPriceCategory,
+ onCategorySelected: (GoodPriceCategory) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val categories = GoodPriceCategory.entries
+
+ LazyRow(
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(categories, key = { it.name }) { category ->
+ val isSelected = category == selectedCategory
+
+ // 필터 칩 아이템 (테두리 없는 깔끔한 캡슐형 칩)
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(if (isSelected) Primary else White)
+ .clickable { onCategorySelected(category) }
+ .padding(horizontal = 20.dp, vertical = 9.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = category.displayName,
+ // Body3 글꼴을 사용하며, 선택된 경우에만 Bold 처리
+ style = EatssuTheme.typography.body3.copy(
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
+ ),
+ color = if (isSelected) White else Color(0xFF8E8E93),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceStoreBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceStoreBottomSheet.kt
new file mode 100644
index 000000000..09fb7d4f5
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/goodprice/component/GoodPriceStoreBottomSheet.kt
@@ -0,0 +1,141 @@
+package com.eatssu.android.presentation.goodprice.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+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.common.enums.GoodPriceCategory
+import com.eatssu.design_system.theme.Black
+import com.eatssu.design_system.theme.EatssuTheme
+import com.eatssu.design_system.theme.Gray600
+import com.eatssu.design_system.theme.White
+
+/**
+ * 착한가격업소 상세 정보 바텀시트
+ * 글꼴 가이드:
+ * - 식당명: H2
+ * - 식당타입: Body2
+ * - 주소: Body1
+ * - 메뉴: Body3
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GoodPriceStoreBottomSheet(
+ storeDetail: GoodPriceStoreDetail,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ // 카테고리별 마커 아이콘 리소스 매핑
+ val iconRes = when (storeDetail.category) {
+ GoodPriceCategory.BAKERY -> R.drawable.ic_map_marker_bakery
+ GoodPriceCategory.ETC -> R.drawable.ic_map_marker_cafe
+ else -> R.drawable.ic_map_marker_restaurant
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = White,
+ dragHandle = { BottomSheetDefaults.DragHandle() },
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 36.dp, top = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ // 좌측 정보 영역 (업소명, 카테고리, 주소, 메뉴/가격)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = if (!storeDetail.imageUrl.isNullOrBlank()) 16.dp else 0.dp),
+ ) {
+ // 식당명: H2
+ Text(
+ text = storeDetail.storeName,
+ style = EatssuTheme.typography.h2,
+ color = Black,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // 식당타입: Body2
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = storeDetail.category.displayName,
+ style = EatssuTheme.typography.body2,
+ color = Gray600,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 주소: Body1
+ if (!storeDetail.roadAddress.isNullOrBlank()) {
+ Text(
+ text = storeDetail.roadAddress,
+ style = EatssuTheme.typography.body1,
+ color = Black,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+
+ // 메뉴: Body3
+ if (!storeDetail.mainMenu.isNullOrBlank() || storeDetail.price != null) {
+ val priceText = storeDetail.price?.let { " %,d원".format(it) } ?: ""
+ Text(
+ text = "${storeDetail.mainMenu ?: ""}$priceText",
+ style = EatssuTheme.typography.body3,
+ color = Gray600,
+ )
+ }
+ }
+
+ // 우측 업소 대표 이미지 (URL이 존재하는 경우 표시)
+ if (!storeDetail.imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = storeDetail.imageUrl,
+ contentDescription = storeDetail.storeName,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt
index b643e1dbf..3a8838fc0 100644
--- a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt
@@ -1,63 +1,83 @@
package com.eatssu.android.presentation.login
import android.os.Bundle
-import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
-import androidx.lifecycle.Lifecycle
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
import com.eatssu.android.R
-import com.eatssu.android.databinding.ActivityLoginBinding
+import com.eatssu.android.analytics.ProvideAnalyticsTracker
import com.eatssu.android.presentation.MainActivity
-import com.eatssu.android.presentation.base.BaseActivity
import com.eatssu.android.presentation.util.showErrorToast
import com.eatssu.android.presentation.util.showToast
import com.eatssu.android.presentation.util.startActivity
import com.eatssu.common.UiEvent
import com.eatssu.common.UiState
+import com.eatssu.common.analytics.AnalyticsTracker
import com.eatssu.common.analytics.CredentialsEvent
-import com.eatssu.common.enums.ScreenId
+import com.eatssu.design_system.theme.EatssuTheme
import com.kakao.sdk.common.model.ClientError
import com.kakao.sdk.common.model.ClientErrorCause
import com.kakao.sdk.user.UserApiClient
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
-
+import javax.inject.Inject
@AndroidEntryPoint
-class LoginActivity :
- BaseActivity(
- ActivityLoginBinding::inflate,
- ScreenId.LOGIN_LOGIN
- ) {
+class LoginActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var analyticsTracker: AnalyticsTracker
private val loginViewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- initUi()
- observeState()
- observeEvents()
- }
+ enableEdgeToEdge()
- private fun initUi() {
- // 툴바 숨기기
- with(toolbar) {
- visibility = View.GONE
- setSupportActionBar(this)
- supportActionBar?.apply {
- setDisplayHomeAsUpEnabled(false)
- setDisplayShowTitleEnabled(false)
- }
- }
+ setContent {
+ ProvideAnalyticsTracker(analyticsTracker) {
+ EatssuTheme {
+ val uiState by loginViewModel.uiState.collectAsStateWithLifecycle()
- binding.ibKakaoLogin.setOnClickListener {
- handleKakaoLogin()
+ LaunchedEffect(uiState) {
+ if (uiState is UiState.Success) {
+ startActivity()
+ finishAffinity()
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ loginViewModel.uiEvent.collect { event ->
+ when (event) {
+ is UiEvent.ShowToast -> showToast(event)
+ }
+ }
+ }
+
+ BackHandler {
+ finishAffinity()
+ }
+
+ LoginScreen(
+ isLoading = uiState is UiState.Loading,
+ onKakaoLoginClick = { handleKakaoLogin() },
+ onBrowseGoodPriceStoreClick = {
+ startActivity()
+ },
+ )
+ }
+ }
}
}
- //kakao login sdk를 통해 유저 정보를 가져와 rest api 호출하는 뷰모델 함수 호출
+ // kakao login sdk를 통해 유저 정보를 가져와 rest api 호출하는 뷰모델 함수 호출
private fun handleKakaoLogin() {
lifecycleScope.launch {
analyticsTracker.track(CredentialsEvent.ClickLoginEvent("kakao"))
@@ -71,7 +91,11 @@ class LoginActivity :
val email = user.kakaoAccount?.email.toString()
loginViewModel.getKakaoLogin(email, providerID)
analyticsTracker.track(CredentialsEvent.CompleteLoginEvent("kakao"))
- } ?: Timber.e(error, "User info fetch failed")
+ } ?: run {
+ Timber.e(error, "User info fetch failed")
+ loginViewModel.setInitState()
+ showErrorToast(R.string.toast_login_failed)
+ }
}
} catch (error: Throwable) {
Timber.e(error, "Kakao login failed")
@@ -80,7 +104,7 @@ class LoginActivity :
}
}
- //kakao login sdk의 error를 다룹니다.
+ // kakao login sdk의 error를 다룹니다.
private fun handleKakaoLoginError(error: Throwable) {
when {
error is ClientError && error.reason == ClientErrorCause.Cancelled -> {
@@ -90,49 +114,9 @@ class LoginActivity :
else -> {
Timber.e(error, "Login failed")
+ loginViewModel.setInitState()
showErrorToast(R.string.toast_login_failed)
}
}
}
-
- private fun observeState() {
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- loginViewModel.uiState.collect { state ->
- when (state) {
- is UiState.Loading -> showLoading(true)
- is UiState.Success -> {
- startActivity()
- finishAffinity()
- }
- else -> {
- showLoading(false)
- }
- }
- }
- }
- }
- }
-
- private fun observeEvents() {
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- loginViewModel.uiEvent.collect { event ->
- when (event) {
- is UiEvent.ShowToast -> showToast(event)
- }
- }
- }
- }
- }
-
- private fun showLoading(isLoading: Boolean) {
- binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
- binding.ibKakaoLogin.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE
- }
-
- override fun onBackPressed() {
- super.onBackPressed()
- finishAffinity() //로그인 화면에서 뒤로 가기 눌렀을 때에는 백스택 없어야 함 (앱 종료)
- }
}
diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginScreen.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginScreen.kt
new file mode 100644
index 000000000..12e8b2c58
--- /dev/null
+++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginScreen.kt
@@ -0,0 +1,234 @@
+package com.eatssu.android.presentation.login
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.eatssu.android.R
+import com.eatssu.android.presentation.util.TrackScreenViewEvent
+import com.eatssu.common.enums.ScreenId
+import com.eatssu.design_system.theme.Black
+import com.eatssu.design_system.theme.EatssuTheme
+import com.eatssu.design_system.theme.Primary
+import com.eatssu.design_system.theme.Secondary
+import com.eatssu.design_system.theme.White
+
+/**
+ * 로그인 화면 Composable
+ */
+@Composable
+fun LoginScreen(
+ isLoading: Boolean,
+ onKakaoLoginClick: () -> Unit,
+ onBrowseGoodPriceStoreClick: () -> Unit, // 착한가격업소 둘러보기 클릭 콜백
+ modifier: Modifier = Modifier,
+) {
+ TrackScreenViewEvent(screenId = ScreenId.LOGIN_LOGIN)
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .padding(horizontal = 30.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.weight(1.2f))
+
+ // 로고
+ Image(
+ painter = painterResource(id = R.drawable.img_new_logo_primary),
+ contentDescription = "Eat SSU Logo",
+ modifier = Modifier.height(75.dp),
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // 슬로건
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(id = R.string.app_slogan_part1) + " ",
+ style = EatssuTheme.typography.h2,
+ color = Black,
+ )
+ Text(
+ text = stringResource(id = R.string.app_slogan_part2),
+ style = EatssuTheme.typography.h2,
+ color = Primary,
+ )
+ val part3 = stringResource(id = R.string.app_slogan_part3)
+ if (part3.isNotEmpty()) {
+ Text(
+ text = part3,
+ style = EatssuTheme.typography.h2,
+ color = Black,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1.5f))
+
+ // 카카오 로그인 버튼 / 로딩 인디케이터
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ color = Primary,
+ )
+ } else {
+ Image(
+ painter = painterResource(id = R.drawable.img_kakao_login_btn),
+ contentDescription = "Kakao Login",
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onKakaoLoginClick),
+ )
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 20.dp),
+ )
+
+ // 착한가격업소 둘러보기 버튼 (로그인 없이 바로 지도 화면으로 이동)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .border(
+ color = Primary,
+ width = 1.dp,
+ shape = RoundedCornerShape(8.dp),
+ )
+ .background(Secondary)
+ .clickable { onBrowseGoodPriceStoreClick() },
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(
+ vertical = 14.dp,
+ horizontal = 16.dp,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ modifier = Modifier.size(32.dp),
+ painter = painterResource(R.drawable.ic_map_marker_restaurant),
+ contentDescription = null,
+ )
+
+ Column {
+ Text(
+ text = stringResource(id = R.string.btn_browse_good_price_stores),
+ style = EatssuTheme.typography.button2,
+ color = Black,
+ )
+
+ Text(
+ text = "내 주변 착한 한 끼를 찾아보세요",
+ style = EatssuTheme.typography.caption2,
+ color = Black,
+ )
+ }
+
+ Spacer(Modifier.weight(1f))
+
+ Icon(
+ painter = painterResource(R.drawable.ic_arrow_right),
+ tint = Primary,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+
+ // 서울시 로고 영역
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(top = 50.dp)
+ .height(30.dp),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.seoul_logo_kr),
+ contentDescription = "KOR Seoul Logo",
+ modifier = Modifier.weight(1f),
+ )
+ VerticalDivider(
+ modifier = Modifier.width(1.dp),
+ )
+ Image(
+ painter = painterResource(R.drawable.seoul_logo_en),
+ contentDescription = "ENG Seoul Logo",
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LoginScreenPreview() {
+ EatssuTheme {
+ LoginScreen(
+ isLoading = false,
+ onKakaoLoginClick = {},
+ onBrowseGoodPriceStoreClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LoginScreenLoadingPreview() {
+ EatssuTheme {
+ LoginScreen(
+ isLoading = true,
+ onKakaoLoginClick = {},
+ onBrowseGoodPriceStoreClick = {},
+ )
+ }
+}
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 0994d5b08..ad3dff39b 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
@@ -15,19 +15,24 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TabRowDefaults
+import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
@@ -35,8 +40,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,6 +52,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -58,6 +67,7 @@ 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.goodprice.GoodPriceMapRoute
import com.eatssu.android.presentation.map.component.DepartmentBottomSheet
import com.eatssu.android.presentation.map.component.FilterType
import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet
@@ -72,9 +82,12 @@ import com.eatssu.common.analytics.MapAnalyticsEvent
import com.eatssu.common.enums.ScreenId
import com.eatssu.common.enums.StoreType
import com.eatssu.common.enums.ToastType
+import com.eatssu.design_system.theme.Black
import com.eatssu.design_system.theme.EatssuTheme
import com.eatssu.design_system.theme.Gray300
+import com.eatssu.design_system.theme.Gray500
import com.eatssu.design_system.theme.Primary
+import com.eatssu.design_system.theme.White
import com.naver.maps.geometry.LatLng
import com.naver.maps.map.CameraPosition
import com.naver.maps.map.CameraUpdate
@@ -97,6 +110,9 @@ private const val DEFAULT_LONGITUDE = 126.95661313346206
private const val DEFAULT_ZOOM = 14.5
private const val PERMISSION_REQUEST_CODE = 1001
+/**
+ * 숭실대 사용자 지도 화면 (학교 제휴 / 착한 가격 탭 전환 지원)
+ */
@Composable
fun MapRoute(
viewModel: MapViewModel = viewModel(),
@@ -123,6 +139,9 @@ fun MapRoute(
val departmentId by viewModel.departmentId.collectAsStateWithLifecycle()
val collegeId by viewModel.collegeId.collectAsStateWithLifecycle()
+ // 상단 탭 인덱스 (0: 학교 제휴, 1: 착한 가격)
+ var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }
+
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition(
LatLng(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
@@ -156,7 +175,6 @@ fun MapRoute(
else -> "학과" to false
}
}
-
else -> "학과" to false
}
@@ -185,18 +203,18 @@ fun MapRoute(
}
}
- // 상태 변화 감지해서 show/hide -> Scrim 잔존 문제 해결
- LaunchedEffect(showUserDepartmentBottomSheet) {
- if (showUserDepartmentBottomSheet) {
+ // [학교 제휴 탭] 학과 미입력 시 바텀시트 노출
+ LaunchedEffect(showUserDepartmentBottomSheet, selectedTabIndex) {
+ if (selectedTabIndex == 0 && showUserDepartmentBottomSheet) {
departmentSheetState.show()
- } else {
+ } else if (selectedTabIndex == 1) {
departmentSheetState.hide()
}
}
- // 필터 변경 결과에 따라 학과 입력 BottomSheet 표시
- LaunchedEffect(mapState.filterChangeResult) {
- if (mapState.filterChangeResult is MapState.FilterChangeResult.RequiresDepartment) {
+ // [학교 제휴 탭] 필터 변경 결과(RequiresDepartment)에 따라 학과 입력 BottomSheet 표시
+ LaunchedEffect(mapState.filterChangeResult, selectedTabIndex) {
+ if (selectedTabIndex == 0 && mapState.filterChangeResult is MapState.FilterChangeResult.RequiresDepartment) {
departmentSheetState.show()
}
}
@@ -214,12 +232,13 @@ fun MapRoute(
val lifecycleOwner = LocalLifecycleOwner.current
- // onResume 시마다 학과 정보 반영
+ // onResume 시마다 학과 정보 및 제휴 데이터 갱신
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
- Timber.d("MapFragmentComposeView: onResume -> 학과 정보 갱신")
+ Timber.d("MapFragmentComposeView: onResume -> 학과 정보 및 제휴 데이터 갱신")
mainViewModel.refreshUserDepartment()
+ viewModel.refresh()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -229,68 +248,155 @@ fun MapRoute(
}
}
- MapScreen(
- mapState = mapState,
- viewModel = viewModel,
- cameraPositionState = cameraPositionState,
- locationSource = locationSource,
- departmentSheetState = departmentSheetState,
- partnershipSheetState = partnershipSheetState,
- showToast = { uiText, info ->
- scope.launch {
- 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,
+ Scaffold(
+ topBar = {
+ Column(modifier = Modifier.background(White)) {
+ // 상단 타이틀: 지도
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.nav_map),
+ style = EatssuTheme.typography.subtitle1,
+ color = Black,
+ )
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = White,
+ ),
+ windowInsets = TopAppBarDefaults.windowInsets,
+ )
+
+ // 상단 탭: 학교 제휴 | 착한 가격
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ containerColor = White,
+ contentColor = Primary,
+ indicator = { tabPositions ->
+ TabRowDefaults.SecondaryIndicator(
+ modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
+ color = Primary,
+ height = 2.dp,
+ )
+ },
+ divider = {
+ HorizontalDivider(color = Gray300, thickness = 0.5.dp)
+ },
+ ) {
+ Tab(
+ selected = selectedTabIndex == 0,
+ onClick = { selectedTabIndex = 0 },
+ text = {
+ Text(
+ text = stringResource(R.string.tab_school_partnership),
+ style = EatssuTheme.typography.subtitle2.copy(
+ fontWeight = if (selectedTabIndex == 0) FontWeight.Bold else FontWeight.Normal,
+ ),
+ color = if (selectedTabIndex == 0) Primary else Gray500,
+ )
+ },
+ )
+
+ Tab(
+ selected = selectedTabIndex == 1,
+ onClick = { selectedTabIndex = 1 },
+ text = {
+ Text(
+ text = stringResource(R.string.tab_good_price),
+ style = EatssuTheme.typography.subtitle2.copy(
+ fontWeight = if (selectedTabIndex == 1) FontWeight.Bold else FontWeight.Normal,
+ ),
+ color = if (selectedTabIndex == 1) Primary else Gray500,
+ )
+ },
)
}
}
},
- navigateToUserInfo = {
- val intent = Intent(context, UserInfoActivity::class.java)
- context.startActivity(intent)
- },
- onHideDepartmentSheet = {
- scope.launch { departmentSheetState.hide() }
- },
- onHidePartnershipSheet = {
- scope.launch {
- partnershipSheetState.hide()
- viewModel.clearSelectedPartnership()
- }
- },
- animateCameraPositionTo = { position, currentZoom ->
- scope.launch {
- cameraPositionState.animate(
- CameraUpdate.toCameraPosition(
- CameraPosition(
- position,
- currentZoom + 2.0
- )
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ when (selectedTabIndex) {
+ // 0: 학교 제휴 탭 (숭실대 중심 제휴 지도)
+ 0 -> {
+ MapScreen(
+ mapState = mapState,
+ viewModel = viewModel,
+ cameraPositionState = cameraPositionState,
+ locationSource = locationSource,
+ departmentSheetState = departmentSheetState,
+ partnershipSheetState = partnershipSheetState,
+ showToast = { uiText, info ->
+ scope.launch { 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)
+ },
+ onHideDepartmentSheet = {
+ scope.launch {
+ departmentSheetState.hide()
+ viewModel.clearFilterChangeResult()
+ }
+ },
+ onHidePartnershipSheet = {
+ scope.launch {
+ partnershipSheetState.hide()
+ viewModel.clearSelectedPartnership()
+ }
+ },
+ animateCameraPositionTo = { position, currentZoom ->
+ scope.launch {
+ cameraPositionState.animate(
+ CameraUpdate.toCameraPosition(
+ CameraPosition(
+ position,
+ currentZoom + 2.0
+ )
+ )
+ )
+ }
+ },
+ onSelectedFilterChange = { filter ->
+ viewModel.setFilter(filter)
+ },
+ departmentId = departmentId,
+ collegeId = collegeId,
+ departmentName = departmentName,
+ selectedFilter = mapState.selectedFilter,
+ onFavoriteClick = onFavoriteClick,
)
- )
+ }
+
+ // 1: 착한 가격 탭 (서울시 전역 착한가격업소 지도, 학과 바텀시트 불필요)
+ 1 -> {
+ GoodPriceMapRoute(
+ showTopBar = false,
+ contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.bottom_nav_height)),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
}
- },
- onSelectedFilterChange = { filter ->
- viewModel.setFilter(filter)
- },
- departmentId = departmentId,
- collegeId = collegeId,
- departmentName = departmentName,
- selectedFilter = mapState.selectedFilter,
- onFavoriteClick = onFavoriteClick,
- )
+ }
+ }
}
@Composable
@@ -316,216 +422,188 @@ internal fun MapScreen(
) {
val analyticsTracker = LocalAnalyticsTracker.current
- Scaffold(
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(
- text = stringResource(R.string.title_partnership_map),
- style = EatssuTheme.typography.subtitle1
- )
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 17.dp),
- windowInsets = TopAppBarDefaults.windowInsets
- )
- },
- ) { innerPadding ->
-
- // 학과 정보가 없을 때 보여줄 BottomSheet
- if (departmentSheetState.isVisible) {
- Timber.d("학과 정보가 없습니다. BottomSheet를 표시합니다.")
-
- DepartmentBottomSheet(
- onDismiss = {
- onHideDepartmentSheet()
- },
- onInputClick = {
- onHideDepartmentSheet()
- navigateToUserInfo()
- },
- sheetState = departmentSheetState
- )
- }
-
- // 특정 식당에 대한 제휴 정보 BottomSheet
- if (partnershipSheetState.isVisible) {
- mapState.restaurantPartnershipInfo?.let { info ->
- mapState.storeType?.let { storeType ->
- LaunchedEffect(info.id, collegeId, departmentId) {
- analyticsTracker.track(
- MapAnalyticsEvent.PartnerRestaurantClicked(
- college = collegeId,
- major = departmentId,
- partnerRestaurantId = info.id.toLong(),
- ),
- )
- }
+ // 학과 정보가 없을 때 보여줄 BottomSheet
+ if (departmentSheetState.isVisible) {
+ Timber.d("학과 정보가 없습니다. BottomSheet를 표시합니다.")
+
+ DepartmentBottomSheet(
+ onDismiss = {
+ onHideDepartmentSheet()
+ },
+ onInputClick = {
+ onHideDepartmentSheet()
+ navigateToUserInfo()
+ },
+ sheetState = departmentSheetState,
+ )
+ }
- MapRestaurantBottomSheet(
- storeName = info.storeName,
- storeType = storeType,
- isLike = info.likedByUser,
- mapRestaurantList = mapState.restaurantInfoList,
- onNaverMapClick = {
- openMap(MapProvider.NAVER, info)
- },
- onKakaoMapClick = {
- openMap(MapProvider.KAKAO, info)
- },
- onDismiss = {
- onHidePartnershipSheet()
- },
- onLikeClick = {
- viewModel.likePartnership(info.id)
- }
+ // 특정 식당에 대한 제휴 정보 BottomSheet
+ if (partnershipSheetState.isVisible) {
+ mapState.restaurantPartnershipInfo?.let { info ->
+ mapState.storeType?.let { storeType ->
+ LaunchedEffect(info.id, collegeId, departmentId) {
+ analyticsTracker.track(
+ MapAnalyticsEvent.PartnerRestaurantClicked(
+ college = collegeId,
+ major = departmentId,
+ partnerRestaurantId = info.id.toLong(),
+ ),
)
}
- }
- }
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- contentAlignment = Alignment.TopCenter,
- ) {
- NaverMap(
- modifier = Modifier.fillMaxSize(),
- cameraPositionState = cameraPositionState,
- uiSettings = MapUiSettings(
- isZoomControlEnabled = false,
- isLocationButtonEnabled = true
- ),
- locationSource = locationSource,
- contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.bottom_nav_height)),
- properties = MapProperties(
- // 현재 다른 위치에 있는 경우에도 숭실대입구를 보여주어야 함
- locationTrackingMode = LocationTrackingMode.NoFollow,
- ),
- ) {
- val clusterItems = mapState.partnerships.associateBy {
- ItemKey(
- it.storeName,
- LatLng(it.latitude, it.longitude)
- )
- }
-
- Clustering(
- items = clusterItems,
- thresholdStrategy = {
- // 줌 레벨에 상관 없이 임의의 값 사용
- 25.0
+ MapRestaurantBottomSheet(
+ storeName = info.storeName,
+ storeType = storeType,
+ isLike = info.likedByUser,
+ mapRestaurantList = mapState.restaurantInfoList,
+ onNaverMapClick = {
+ openMap(MapProvider.NAVER, info)
},
-
- clusterContent = {
- Box(
- modifier = Modifier
- .size(32.dp)
- .background(Primary, CircleShape),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = "${it.size}",
- color = Color.White,
- style = EatssuTheme.typography.body2
- )
- }
+ onKakaoMapClick = {
+ openMap(MapProvider.KAKAO, info)
},
- leafContent = { info ->
- val partnership = info.tag as? Partnership ?: return@Clustering
-
- Row(
- modifier = Modifier
- .background(Color.White, RoundedCornerShape(13.dp))
- .border(1.dp, Gray300, RoundedCornerShape(13.dp))
- .padding(
- start = 3.dp,
- end = 7.dp,
- top = 2.5.dp,
- bottom = 2.5.dp
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = spacedBy(
- 3.dp
- )
- ) {
- val iconRes = when (partnership.restaurantType) {
- StoreType.CAFE -> R.drawable.ic_map_marker_cafe
- StoreType.PUB -> R.drawable.ic_map_marker_pub
- else -> R.drawable.ic_map_marker_restaurant
- }
-
- Image(
- painter = painterResource(id = iconRes),
- contentDescription = null,
- modifier = Modifier.size(20.dp)
- )
-
- Text(
- text = partnership.storeName,
- style = EatssuTheme.typography.caption3,
- color = Color.Black
- )
- }
+ onDismiss = {
+ onHidePartnershipSheet()
},
- onClickCluster = { info, _ ->
- animateCameraPositionTo(info.position, cameraPositionState.position.zoom)
- true
+ onLikeClick = {
+ viewModel.likePartnership(info.id)
},
- onClickLeaf = { info, _ ->
- val partnership = info.tag as? Partnership ?: return@Clustering true
-
- if (partnership.partnershipInfos.isEmpty()) {
- // 제휴 정보가 없을 때는 토스트만 띄우고 바텀시트는 안 띄움
- showToast(
- UiText.StringResource(R.string.toast_partnership_info_not_found),
- ToastType.INFO
- )
- } else {
- // 제휴 정보가 있을 때만 바텀시트 띄움
- viewModel.selectPartnershipByStoreName(partnership.storeName)
- }
- true
- }
+ )
+ }
+ }
+ }
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ NaverMap(
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ uiSettings = MapUiSettings(
+ isZoomControlEnabled = false,
+ isLocationButtonEnabled = true,
+ ),
+ locationSource = locationSource,
+ contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.bottom_nav_height)),
+ properties = MapProperties(
+ // 현재 다른 위치에 있는 경우에도 숭실대입구를 보여주어야 함
+ locationTrackingMode = LocationTrackingMode.NoFollow,
+ ),
+ ) {
+ val clusterItems = mapState.partnerships.associateBy {
+ ItemKey(
+ it.storeName,
+ LatLng(it.latitude, it.longitude),
)
}
- // 학과 정보를 입력하지 않은 상태에서 제휴 필터를 변경하려고 할 때 BottomSheet 표시
- // 학과 정보가 없으면 제휴 필터를 변경할 수 없음
- PartnershipFilterToggle(
- selected = selectedFilter,
- onSelectedChange = { next ->
- if (partnershipSheetState.isVisible) return@PartnershipFilterToggle
- onSelectedFilterChange(next)
+ Clustering(
+ items = clusterItems,
+ thresholdStrategy = { 25.0 },
+ clusterContent = {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(Primary, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "${it.size}",
+ color = Color.White,
+ style = EatssuTheme.typography.body2,
+ )
+ }
+ },
+ leafContent = { info ->
+ val partnership = info.tag as? Partnership ?: return@Clustering
+
+ Row(
+ modifier = Modifier
+ .background(Color.White, RoundedCornerShape(13.dp))
+ .border(1.dp, Gray300, RoundedCornerShape(13.dp))
+ .padding(
+ start = 3.dp,
+ end = 7.dp,
+ top = 2.5.dp,
+ bottom = 2.5.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = spacedBy(3.dp),
+ ) {
+ val iconRes = when (partnership.restaurantType) {
+ StoreType.CAFE -> R.drawable.ic_map_marker_cafe
+ StoreType.PUB -> R.drawable.ic_map_marker_pub
+ else -> R.drawable.ic_map_marker_restaurant
+ }
+
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+
+ Text(
+ text = partnership.storeName,
+ style = EatssuTheme.typography.caption3,
+ color = Color.Black,
+ )
+ }
+ },
+ onClickCluster = { info, _ ->
+ animateCameraPositionTo(info.position, cameraPositionState.position.zoom)
+ true
+ },
+ onClickLeaf = { info, _ ->
+ val partnership = info.tag as? Partnership ?: return@Clustering true
+
+ if (partnership.partnershipInfos.isEmpty()) {
+ // 제휴 정보가 없을 때는 토스트만 띄우고 바텀시트는 안 띄움
+ showToast(
+ UiText.StringResource(R.string.toast_partnership_info_not_found),
+ ToastType.INFO,
+ )
+ } else {
+ // 제휴 정보가 있을 때만 바텀시트 띄움
+ viewModel.selectPartnershipByStoreName(partnership.storeName)
+ }
+ true
},
- modifier = Modifier.padding(top = 12.dp),
- departmentName = departmentName.toString(),
- filters = mapState.availableFilters,
)
+ }
- Surface(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(
- end = 18.dp,
- bottom = dimensionResource(R.dimen.bottom_nav_height) + 18.dp,
- )
- .size(48.dp),
- shape = CircleShape,
- color = Color.White,
- shadowElevation = 4.dp,
- onClick = onFavoriteClick,
- ) {
- Box(contentAlignment = Alignment.Center) {
- Image(
- painter = painterResource(R.drawable.ic_like_selected),
- contentDescription = stringResource(R.string.favorite_open),
- modifier = Modifier.size(24.dp),
- )
- }
+ // 단과대 제휴 토글 버튼
+ PartnershipFilterToggle(
+ selected = selectedFilter,
+ onSelectedChange = { next ->
+ if (partnershipSheetState.isVisible) return@PartnershipFilterToggle
+ onSelectedFilterChange(next)
+ },
+ modifier = Modifier.padding(top = 12.dp),
+ departmentName = departmentName.toString(),
+ filters = mapState.availableFilters,
+ )
+
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(
+ end = 18.dp,
+ bottom = dimensionResource(R.dimen.bottom_nav_height) + 18.dp,
+ )
+ .size(48.dp),
+ shape = CircleShape,
+ color = Color.White,
+ shadowElevation = 4.dp,
+ onClick = onFavoriteClick,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Image(
+ painter = painterResource(R.drawable.ic_like_selected),
+ contentDescription = stringResource(R.string.favorite_open),
+ modifier = Modifier.size(24.dp),
+ )
}
}
}
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 fdde27143..d6cf370f3 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
@@ -74,6 +74,17 @@ class MapViewModel @Inject constructor(
fetchUserCollegeDepartment()
}
+ fun refresh() {
+ fetchUserCollegeDepartment()
+ }
+
+ fun clearFilterChangeResult() {
+ val current = uiState.value
+ if (current is UiState.Success) {
+ _uiState.value = UiState.Success(current.data.copy(filterChangeResult = null))
+ }
+ }
+
private fun fetchUserCollegeDepartment() {
viewModelScope.launch {
val userCollegeDepartment = getUserCollegeDepartmentUseCase()
@@ -92,23 +103,35 @@ class MapViewModel @Inject constructor(
listOf(FilterType.Mine, FilterType.All)
}
- // Festival 제휴가 하나라도 있으면 Festival을 우선하고, 없으면 기존 기본 필터 규칙을 따른다.
+ // Festival 제휴가 하나라도 있으면 Festival을 우선하고, 없으면 Mine을 기본으로 설정한다.
val initialFilter = when {
hasFestival -> FilterType.Festival
- newDepartmentId == -1L -> FilterType.All
else -> FilterType.Mine
}
- _uiState.value = UiState.Success(
- MapState(
- selectedFilter = initialFilter,
- availableFilters = availableFilters,
- ),
- )
- when (initialFilter) {
- FilterType.All -> loadPartnerships(prefetchedPartnerships = allPartnerships)
- FilterType.Festival -> loadFestivalPartnerships(prefetchedPartnerships = allPartnerships)
- FilterType.Mine -> loadUserCollegePartnerships()
+ if (newDepartmentId == -1L && initialFilter == FilterType.Mine) {
+ // 학과 미입력 유저: 바텀시트 요청 상태 설정, 마커 데이터는 빈 상태(emptyList)로 둠
+ _uiState.value = UiState.Success(
+ MapState(
+ selectedFilter = FilterType.Mine,
+ availableFilters = availableFilters,
+ filterChangeResult = MapState.FilterChangeResult.RequiresDepartment,
+ partnerships = emptyList(),
+ ),
+ )
+ } else {
+ _uiState.value = UiState.Success(
+ MapState(
+ selectedFilter = initialFilter,
+ availableFilters = availableFilters,
+ ),
+ )
+
+ when (initialFilter) {
+ FilterType.All -> loadPartnerships(prefetchedPartnerships = allPartnerships)
+ FilterType.Festival -> loadFestivalPartnerships(prefetchedPartnerships = allPartnerships)
+ FilterType.Mine -> loadUserCollegePartnerships()
+ }
}
analyticsTracker.track(
@@ -130,10 +153,14 @@ class MapViewModel @Inject constructor(
// 학과 정보가 없는데 Mine 필터를 선택하려는 경우
if (filter == FilterType.Mine && _departmentId.value == -1L) {
- // 학과 입력이 필요한 경우 결과를 MapState에 반영
+ // 학과 입력이 필요한 경우 결과를 MapState에 반영하고 마커는 비어있는 상태 유지
if (current is UiState.Success) {
_uiState.value = UiState.Success(
- currentData.copy(filterChangeResult = MapState.FilterChangeResult.RequiresDepartment)
+ currentData.copy(
+ selectedFilter = FilterType.Mine,
+ filterChangeResult = MapState.FilterChangeResult.RequiresDepartment,
+ partnerships = emptyList(),
+ )
)
}
return
diff --git a/app/src/main/java/com/eatssu/android/presentation/util/ActivityUtil.kt b/app/src/main/java/com/eatssu/android/presentation/util/ActivityUtil.kt
index 915f01523..811d4e493 100644
--- a/app/src/main/java/com/eatssu/android/presentation/util/ActivityUtil.kt
+++ b/app/src/main/java/com/eatssu/android/presentation/util/ActivityUtil.kt
@@ -9,7 +9,7 @@ import com.eatssu.android.presentation.base.NetworkErrorEventBus
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
-inline fun AppCompatActivity.startActivity(block: Intent.() -> Unit = {}) {
+inline fun Activity.startActivity(block: Intent.() -> Unit = {}) {
startActivity(Intent(this, T::class.java).apply(block))
}
diff --git a/app/src/main/res/drawable/ic_map_marker_bakery.xml b/app/src/main/res/drawable/ic_map_marker_bakery.xml
new file mode 100644
index 000000000..5e2b72941
--- /dev/null
+++ b/app/src/main/res/drawable/ic_map_marker_bakery.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/seoul_logo_en.png b/app/src/main/res/drawable/seoul_logo_en.png
new file mode 100644
index 000000000..7fe233ce9
Binary files /dev/null and b/app/src/main/res/drawable/seoul_logo_en.png differ
diff --git a/app/src/main/res/drawable/seoul_logo_kr.png b/app/src/main/res/drawable/seoul_logo_kr.png
new file mode 100644
index 000000000..11127e7b3
Binary files /dev/null and b/app/src/main/res/drawable/seoul_logo_kr.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 61ee940ec..23fd6aecd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,7 +26,10 @@
신고하기
위젯 설정
제휴 지도
+ 착한가격업소 지도
강제 업데이트
+ 학교 제휴
+ 착한 가격
@@ -131,6 +134,8 @@
오픈소스 라이브러리를 불러올 수 없어요.
회원탈퇴되었어요.
회원탈퇴에 실패했어요.
+ 업소 정보를 불러오지 못했어요.
+ 착한가격업소 둘러보기
@@ -263,7 +268,7 @@
숭실대에서
- 먹자
+ 서울 곳곳까지
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 6ba0d4075..d6454697d 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
@@ -39,7 +39,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
val getUserCollegeDepartmentUseCase = mockk()
val analyticsTracker = mockk(relaxed = true)
- `when`("학과 정보가 없어서 초기 필터가 전체일 때") {
+ `when`("학과 정보가 없을 때") {
val allPartnerships = listOf(samplePartnership(storeName = "All Cafe"))
coEvery {
getUserCollegeDepartmentUseCase()
@@ -58,15 +58,16 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
analyticsTracker = analyticsTracker,
)
- then("All 필터로 시작하고 전체 제휴 목록을 로드한다") {
+ then("Mine 필터로 시작하고 학과 입력 필요(RequiresDepartment) 상태와 빈 마커 목록을 유지한다") {
runTest {
eventually(2.seconds) {
val state = viewModel.uiState.value as UiState.Success
- state.data.selectedFilter shouldBe FilterType.All
+ state.data.selectedFilter shouldBe FilterType.Mine
state.data.availableFilters shouldBe listOf(FilterType.Mine, FilterType.All)
- state.data.partnerships shouldBe allPartnerships
+ state.data.filterChangeResult shouldBe MapState.FilterChangeResult.RequiresDepartment
+ state.data.partnerships shouldBe emptyList()
}
- coVerify(atLeast = 1) { partnershipRepository.getAllPartnerships() }
+ coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() }
}
}
}
@@ -133,7 +134,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
}
}
- `when`("학과 정보가 없는 사용자가 Mine 필터를 선택하면") {
+ `when`("학과 정보가 없는 사용자가 All에서 Mine 필터로 변경하려고 하면") {
coEvery {
getUserCollegeDepartmentUseCase()
} returns sampleUserInfo(
@@ -153,10 +154,11 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
then("RequiresDepartment 결과를 상태에 반영하고 Mine 데이터를 로드하지 않는다") {
runTest {
+ // All 필터로 변경
+ viewModel.setFilter(FilterType.All)
eventually(2.seconds) {
val state = viewModel.uiState.value as UiState.Success
state.data.selectedFilter shouldBe FilterType.All
- state.data.availableFilters shouldBe listOf(FilterType.Mine, FilterType.All)
}
clearMocks(partnershipRepository, answers = false, recordedCalls = true)
@@ -164,7 +166,9 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({
eventually(2.seconds) {
val state = viewModel.uiState.value as UiState.Success
+ state.data.selectedFilter shouldBe FilterType.Mine
state.data.filterChangeResult shouldBe MapState.FilterChangeResult.RequiresDepartment
+ state.data.partnerships shouldBe emptyList()
}
coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() }
}
diff --git a/core/common/src/main/java/com/eatssu/common/enums/GoodPriceCategory.kt b/core/common/src/main/java/com/eatssu/common/enums/GoodPriceCategory.kt
new file mode 100644
index 000000000..501110468
--- /dev/null
+++ b/core/common/src/main/java/com/eatssu/common/enums/GoodPriceCategory.kt
@@ -0,0 +1,25 @@
+package com.eatssu.common.enums
+
+/**
+ * 착한가격업소 업종 카테고리 Enum
+ * 필터 순서 명세: 전체 / 한식 / 일식 / 양식 / 중식 / 베이커리 / 기타 순
+ */
+enum class GoodPriceCategory(
+ val displayName: String,
+ val serverKey: String?, // 서버 쿼리 파라미터 값 (null인 경우 전체)
+) {
+ ALL("전체", null),
+ KOREAN("한식", "KOREAN"),
+ JAPANESE("일식", "JAPANESE"),
+ WESTERN("양식", "WESTERN"),
+ CHINESE("중식", "CHINESE"),
+ BAKERY("베이커리", "BAKERY"),
+ ETC("기타", "ETC");
+
+ companion object {
+ // 서버 응답 문자열로부터 Category 매핑
+ fun fromServerKey(key: String?): GoodPriceCategory {
+ return entries.firstOrNull { it.serverKey == key } ?: ETC
+ }
+ }
+}
diff --git a/core/common/src/main/java/com/eatssu/common/enums/ScreenId.kt b/core/common/src/main/java/com/eatssu/common/enums/ScreenId.kt
index 1eaa2d76d..b77621ab6 100644
--- a/core/common/src/main/java/com/eatssu/common/enums/ScreenId.kt
+++ b/core/common/src/main/java/com/eatssu/common/enums/ScreenId.kt
@@ -29,6 +29,7 @@ enum class ScreenId(val value: String) {
MAP_DETAIL("MAP_001001"),
MAP_NO_DEPARTMENT("MAP_001002"),
MAP_FAVORITE("MAP_002000"),
+ GOOD_PRICE_MAP("GPM_001000"),
ANYONE_BUT_ME_MAIN("ABM_001000"),
MYPAGE_MAIN("MYP_001000"),