diff --git a/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt index 46696d134..99e2ef3ab 100644 --- a/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt +++ b/app/src/main/java/com/neki/android/app/ui/BottomNavigationBar.kt @@ -86,9 +86,9 @@ fun BottomNavigationBarItem( color = NekiTheme.colorScheme.white, ) { Column( - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( modifier = Modifier.size(24.dp), @@ -99,7 +99,7 @@ fun BottomNavigationBarItem( Text( text = stringResource(tab.iconStringRes), color = textColor, - style = NekiTheme.typography.caption11SemiBold, + style = NekiTheme.typography.caption12SemiBold, ) } } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 7a26d84e2..f389ae002 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -10,5 +10,6 @@ android { dependencies { api(libs.timber) implementation(libs.androidx.security.crypto) + implementation(libs.androidx.core.ktx) -} \ No newline at end of file +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt new file mode 100644 index 000000000..923ce048b --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt @@ -0,0 +1,29 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object LocationPermissionManager { + val LOCATION_PERMISSIONS = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + + fun hasLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PermissionChecker.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PermissionChecker.PERMISSION_GRANTED + } + + fun shouldShowLocationRationale(activity: Activity): Boolean { + return activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt b/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt new file mode 100644 index 000000000..1286aa658 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/PermissionUtils.kt @@ -0,0 +1,13 @@ +package com.neki.android.core.common.permission + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +fun navigateToAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) +} diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index 8ff0f0e1f..2cbcec861 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -5,5 +5,5 @@ plugins { dependencies { implementation(projects.core.model) implementation(libs.kotlinx.coroutines.core) - + api(libs.androidx.datastore.preferences) } \ No newline at end of file diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt new file mode 100644 index 000000000..c2d3a66b4 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.dataapi.datastore + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +object DataStoreKey { + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + + val IS_FIRST_LOCATION_PERMISSION = booleanPreferencesKey("is_first_location_permission") +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt index 74c19b4e5..2ce25268d 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt @@ -1,15 +1,9 @@ package com.neki.android.core.dataapi.repository +import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.flow.Flow interface DataStoreRepository { - suspend fun saveJwtTokens( - accessToken: String, - refreshToken: String, - ) - - fun isSavedJwtTokens(): Flow - fun getAccessToken(): Flow - fun getRefreshToken(): Flow - suspend fun clearTokens() + suspend fun setBoolean(key: Preferences.Key, value: Boolean) + fun getBoolean(key: Preferences.Key): Flow } diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt new file mode 100644 index 000000000..a0227f5df --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.dataapi.repository + +import kotlinx.coroutines.flow.Flow + +interface TokenRepository { + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + fun isSavedTokens(): Flow + fun getAccessToken(): Flow + fun getRefreshToken(): Flow + suspend fun clearTokens() +} diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt index 17cc664db..568cc7405 100644 --- a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt @@ -2,11 +2,8 @@ package com.neki.android.core.data.local.di import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.preferences.preferencesDataStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,21 +11,23 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +private const val AUTH_DATASTORE = "auth_datastore" +private val Context.authDataStore: DataStore by preferencesDataStore(name = AUTH_DATASTORE) + +private const val TOKEN_DATASTORE = "token_datastore" +private val Context.tokenDataStore: DataStore by preferencesDataStore(name = TOKEN_DATASTORE) + @InstallIn(SingletonComponent::class) @Module internal object DataStoreModule { - private const val DATASTORE_NAME = "neki-datastore" + @AuthDataStore + @Singleton + @Provides + fun provideAuthDataStore(@ApplicationContext context: Context): DataStore = context.authDataStore + + @TokenDataStore @Singleton @Provides - fun provideDataStore( - @ApplicationContext context: Context, - ): DataStore { - return PreferenceDataStoreFactory.create( - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { emptyPreferences() }, - ), - produceFile = { context.preferencesDataStoreFile(DATASTORE_NAME) }, - ) - } + fun provideTokenDataStore(@ApplicationContext context: Context): DataStore = context.tokenDataStore } diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt new file mode 100644 index 000000000..87863741f --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.data.local.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TokenDataStore diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt index 5791ea53b..dcc77eca1 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt @@ -8,7 +8,7 @@ import com.neki.android.core.data.remote.model.response.AuthResponse import com.neki.android.core.data.remote.model.response.BasicResponse import com.neki.android.core.data.remote.qualifier.UploadHttpClient import com.neki.android.core.dataapi.auth.AuthEventManager -import com.neki.android.core.dataapi.repository.DataStoreRepository +import com.neki.android.core.dataapi.repository.TokenRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -33,7 +33,6 @@ import io.ktor.http.HttpHeaders import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Singleton @@ -46,7 +45,7 @@ internal object NetworkModule { const val TIME_OUT = 5000L const val UPLOAD_TIME_OUT = 10_000L - val sendWithoutJwtUrls = listOf( + val sendWithoutAuthUrls = listOf( "/api/auth/kakao/login", "/api/auth/refresh", ) @@ -67,7 +66,7 @@ internal object NetworkModule { @Provides @Singleton fun provideHttpClient( - dataStoreRepository: DataStoreRepository, + tokenRepository: TokenRepository, authEventManager: AuthEventManager, ): HttpClient { return HttpClient(Android) { @@ -80,10 +79,10 @@ internal object NetworkModule { bearer { loadTokens { Timber.d("BearerAuth - loadTokens") - if (dataStoreRepository.isSavedJwtTokens().first()) { + if (tokenRepository.isSavedTokens().first()) { BearerTokens( - accessToken = dataStoreRepository.getAccessToken().firstOrNull() ?: "", - refreshToken = dataStoreRepository.getRefreshToken().firstOrNull() ?: "", + accessToken = tokenRepository.getAccessToken().first(), + refreshToken = tokenRepository.getRefreshToken().first(), ) } else null } @@ -95,12 +94,12 @@ internal object NetworkModule { val response = client.post("/api/auth/refresh") { setBody( RefreshTokenRequest( - refreshToken = dataStoreRepository.getRefreshToken().firstOrNull() ?: "", + refreshToken = tokenRepository.getRefreshToken().first(), ), ) }.body>() - dataStoreRepository.saveJwtTokens( + tokenRepository.saveTokens( accessToken = response.data.accessToken, refreshToken = response.data.refreshToken, ) @@ -111,7 +110,7 @@ internal object NetworkModule { ) } catch (e: Exception) { Timber.e(e) - dataStoreRepository.clearTokens() + tokenRepository.clearTokens() authEventManager.emitTokenExpired() null } @@ -119,12 +118,12 @@ internal object NetworkModule { } sendWithoutRequest { request -> - val shouldNotJwt = sendWithoutJwtUrls.any { + val shouldNotAuth = sendWithoutAuthUrls.any { request.url.encodedPath == it } - Timber.d("Bearer 인증 필요 API 여부 : $shouldNotJwt") - !shouldNotJwt + Timber.d("Bearer 인증 필요 API 여부 : $shouldNotAuth") + !shouldNotAuth } } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index 75e2f4edb..018f02a4c 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -5,11 +5,13 @@ import com.neki.android.core.data.repository.impl.AuthRepositoryImpl import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl import com.neki.android.core.data.repository.impl.PhotoRepositoryImpl +import com.neki.android.core.data.repository.impl.TokenRepositoryImpl import com.neki.android.core.dataapi.auth.AuthEventManager import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.dataapi.repository.DataStoreRepository import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.dataapi.repository.TokenRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -32,6 +34,12 @@ internal interface RepositoryModule { authRepositoryImpl: AuthRepositoryImpl, ): AuthRepository + @Binds + @Singleton + fun bindTokenRepositoryImpl( + tokenRepositoryImpl: TokenRepositoryImpl, + ): TokenRepository + @Binds @Singleton fun bindAuthEventManagerImpl( diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt index 3d02411da..64bd4ca1c 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt @@ -3,53 +3,25 @@ package com.neki.android.core.data.repository.impl import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import com.neki.android.core.common.crypto.CryptoManager import com.neki.android.core.dataapi.repository.DataStoreRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import com.neki.android.core.data.local.di.AuthDataStore import javax.inject.Inject class DataStoreRepositoryImpl @Inject constructor( - private val dataStore: DataStore, + @AuthDataStore private val dataStore: DataStore, ) : DataStoreRepository { - companion object { - private val ACCESS_TOKEN = stringPreferencesKey("access_token") - private val REFRESH_TOKEN = stringPreferencesKey("refresh_token") - } - override suspend fun saveJwtTokens( - accessToken: String, - refreshToken: String, - ) { + override suspend fun setBoolean(key: Preferences.Key, value: Boolean) { dataStore.edit { preferences -> - preferences[ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) - preferences[REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) - } - } - - override fun isSavedJwtTokens(): Flow { - return dataStore.data.map { preferences -> - val accessToken = preferences[ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } - val refreshToken = preferences[REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } - - !accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank() + preferences[key] = value } } - override fun getAccessToken(): Flow { + override fun getBoolean(key: Preferences.Key): Flow { return dataStore.data.map { preferences -> - preferences[ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } + preferences[key] ?: false } } - - override fun getRefreshToken(): Flow { - return dataStore.data.map { preferences -> - preferences[REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } - } - } - - override suspend fun clearTokens() { - dataStore.edit { it.clear() } - } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt new file mode 100644 index 000000000..3ce7c6286 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt @@ -0,0 +1,54 @@ +package com.neki.android.core.data.repository.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.neki.android.core.common.crypto.CryptoManager +import com.neki.android.core.dataapi.datastore.DataStoreKey +import com.neki.android.core.dataapi.repository.TokenRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import com.neki.android.core.data.local.di.TokenDataStore +import javax.inject.Inject + +class TokenRepositoryImpl @Inject constructor( + @TokenDataStore private val dataStore: DataStore, +) : TokenRepository { + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[DataStoreKey.ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) + preferences[DataStoreKey.REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) + } + } + + override fun isSavedTokens(): Flow { + return dataStore.data.map { preferences -> + val accessToken = preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } + val refreshToken = preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } + + !accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank() + } + } + + override fun getAccessToken(): Flow { + return dataStore.data.map { preferences -> + preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + } + } + + override fun getRefreshToken(): Flow { + return dataStore.data.map { preferences -> + preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + } + } + + override suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(DataStoreKey.ACCESS_TOKEN) + preferences.remove(DataStoreKey.REFRESH_TOKEN) + } + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt index db9ac4e23..1d7131d27 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Shadow.kt @@ -124,3 +124,31 @@ fun Modifier.dropdownShadow( canvas.drawOutline(outline, paint) } } + +/** + * Figma pin_shadow 스타일 + * DROP_SHADOW: color #00000066, offset (0, 1), radius 2.5, spread 0 + */ +fun Modifier.pinShadow( + shape: Shape = RectangleShape, + color: Color = Color.Black.copy(alpha = 0.40f), + offsetX: Dp = 0.dp, + offsetY: Dp = 1.dp, + blurRadius: Dp = 2.5.dp, +): Modifier = this.drawBehind { + drawIntoCanvas { canvas -> + val paint = Paint().apply { + asFrameworkPaint().apply { + this.color = Color.Transparent.toArgb() + setShadowLayer( + blurRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + color.toArgb(), + ) + } + } + val outline = shape.createOutline(size, layoutDirection, this) + canvas.drawOutline(outline, paint) + } +} diff --git a/core/designsystem/src/main/res/drawable/icon_rotation.xml b/core/designsystem/src/main/res/drawable/icon_rotation.xml new file mode 100644 index 000000000..b9ad21315 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_rotation.xml @@ -0,0 +1,20 @@ + + + + diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt index 2f17b3892..be41c5717 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt @@ -4,12 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.neki.android.core.dataapi.auth.AuthEventManager import com.neki.android.core.dataapi.repository.AuthRepository -import com.neki.android.core.dataapi.repository.DataStoreRepository +import com.neki.android.core.dataapi.repository.TokenRepository import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -17,7 +16,7 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val authEventManager: AuthEventManager, - private val dataStoreRepository: DataStoreRepository, + private val tokenRepository: TokenRepository, private val authRepository: AuthRepository, ) : ViewModel() { val store: MviIntentStore = @@ -44,10 +43,10 @@ class LoginViewModel @Inject constructor( reduce: (LoginState.() -> LoginState) -> Unit, postSideEffect: (LoginSideEffect) -> Unit, ) = viewModelScope.launch { - if (dataStoreRepository.isSavedJwtTokens().first()) { + if (tokenRepository.isSavedTokens().first()) { Timber.d("JWT 토큰 O") authRepository.updateAccessToken( - refreshToken = dataStoreRepository.getRefreshToken().firstOrNull() ?: "", + refreshToken = tokenRepository.getRefreshToken().first(), ).onSuccess { postSideEffect(LoginSideEffect.NavigateToHome) }.onFailure { @@ -67,7 +66,7 @@ class LoginViewModel @Inject constructor( reduce { copy(isLoading = true) } authRepository.loginWithKakao(idToken) .onSuccess { - dataStoreRepository.saveJwtTokens( + tokenRepository.saveTokens( accessToken = it.accessToken, refreshToken = it.refreshToken, ) diff --git a/feature/map/impl/build.gradle.kts b/feature/map/impl/build.gradle.kts index 406a4850a..00053ab7d 100644 --- a/feature/map/impl/build.gradle.kts +++ b/feature/map/impl/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.compose) + implementation(libs.androidx.activity.compose) } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt index 515741848..505ee659f 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt @@ -8,29 +8,28 @@ import kotlinx.collections.immutable.persistentListOf data class MapState( val isLoading: Boolean = false, - val dragState: DragValue = DragValue.Bottom, - val currentLocation: Pair = Pair(0.0, 0.0), + val currentLocation: Pair? = null, + val dragLevel: DragLevel = DragLevel.FIRST, val brands: ImmutableList = persistentListOf(), - val nearbyBrands: ImmutableList = persistentListOf(), - val focusedMarkerPosition: Pair = Pair(0.0, 0.0), - val selectedBrandInfo: BrandInfo? = null, + val nearbyPhotoBooths: ImmutableList = persistentListOf(), + val focusedMarkerPosition: Pair? = null, + val selectedPhotoBoothInfo: BrandInfo? = null, val isShowInfoDialog: Boolean = false, val isShowDirectionBottomSheet: Boolean = false, + val isShowLocationPermissionDialog: Boolean = false, ) sealed interface MapIntent { data object EnterMapScreen : MapIntent // in 지도 - data class ClickBrandMarker( - val latitude: Double, - val longitude: Double, - ) : MapIntent - - data class ClickDirection( + data class ClickPhotoBoothMarker( val latitude: Double, val longitude: Double, ) : MapIntent + data object ClickDirection : MapIntent + data object ClickRefresh : MapIntent + data class UpdateCurrentLocation(val latitude: Double, val longitude: Double) : MapIntent // in 패널 data object ClickCurrentLocation : MapIntent @@ -38,24 +37,37 @@ sealed interface MapIntent { data object ClickCloseInfoIcon : MapIntent data object ClickToMapChip : MapIntent data class ClickBrand(val brand: Brand) : MapIntent - data class ClickNearBrand(val brandInfo: BrandInfo) : MapIntent - data object ClickCloseBrandCard : MapIntent + data class ClickNearPhotoBooth(val brandInfo: BrandInfo) : MapIntent + data object ClickClosePhotoBoothCard : MapIntent + data object OpenDirectionBottomSheet : MapIntent data object CloseDirectionBottomSheet : MapIntent data class ClickDirectionItem(val app: DirectionApp) : MapIntent - data class ChangeDragValue(val dragValue: DragValue) : MapIntent + data class ChangeDragLevel(val dragLevel: DragLevel) : MapIntent + + // 위치 권한 + data object RequestLocationPermission : MapIntent + data object ShowLocationPermissionDialog : MapIntent + data object DismissLocationPermissionDialog : MapIntent + data object ConfirmLocationPermissionDialog : MapIntent } sealed interface MapEffect { + data object RefreshPhotoBooth : MapEffect data object RefreshCurrentLocation : MapEffect + data object OpenDirectionBottomSheet : MapEffect data class ShowToastMessage(val message: String) : MapEffect - data class MoveCameraToPosition( - val latitude: Double, - val longitude: Double, - ) : MapEffect + data class MoveCameraToPosition(val latitude: Double, val longitude: Double) : MapEffect data class MoveDirectionApp( val app: DirectionApp, + val startLatitude: Double, + val startLongitude: Double, + val endLatitude: Double, + val endLongitude: Double, ) : MapEffect + + data object NavigateToAppSettings : MapEffect + data object RequestLocationPermission : MapEffect } -enum class DragValue { Bottom, Center, Top, Invisible } +enum class DragLevel { FIRST, SECOND, THIRD, INVISIBLE } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt index f90e5bded..c5772cd30 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt @@ -1,9 +1,12 @@ package com.neki.android.feature.map.impl -import android.widget.Toast +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -11,11 +14,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -32,17 +33,22 @@ 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.compose.rememberFusedLocationSource +import com.neki.android.core.common.permission.LocationPermissionManager +import com.neki.android.core.common.permission.navigateToAppSettings +import com.neki.android.core.designsystem.dialog.SingleButtonAlertDialog import com.neki.android.core.designsystem.dialog.WarningDialog -import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.map.impl.component.AnchoredDraggablePanel -import com.neki.android.feature.map.impl.component.BrandMarker import com.neki.android.feature.map.impl.component.DirectionBottomSheet -import com.neki.android.feature.map.impl.component.PanelInvisibleContent +import com.neki.android.feature.map.impl.component.MapRefreshChip +import com.neki.android.feature.map.impl.component.PhotoBoothDetailCard +import com.neki.android.feature.map.impl.component.PhotoBoothMarker import com.neki.android.feature.map.impl.component.ToMapChip import com.neki.android.feature.map.impl.const.DirectionApp import com.neki.android.feature.map.impl.util.DirectionHelper import com.neki.android.feature.map.impl.util.getPlaceName +import kotlinx.coroutines.launch @OptIn(ExperimentalNaverMapApi::class) @Composable @@ -51,23 +57,61 @@ fun MapRoute( ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val activity = LocalActivity.current!! val coroutineScope = rememberCoroutineScope() + var locationTrackingMode by remember { mutableStateOf(LocationTrackingMode.None) } val cameraPositionState = rememberCameraPositionState { position = CameraPosition(LatLng(37.5269278, 126.886225), 17.0) } + var previousShouldShowRationale by remember { mutableStateOf(false) } + + val locationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val isGranted = permissions.values.any { it } + val currentShouldShowRationale = LocationPermissionManager.shouldShowLocationRationale(activity) + + if (isGranted) { + locationTrackingMode = LocationTrackingMode.Follow + } else { + if (!currentShouldShowRationale && !previousShouldShowRationale) { + // 2회 이상 거부 + viewModel.store.onIntent(MapIntent.ShowLocationPermissionDialog) + } + } + } + LaunchedEffect(Unit) { + if (!LocationPermissionManager.hasLocationPermission(context)) { + viewModel.store.onIntent(MapIntent.RequestLocationPermission) + } viewModel.store.onIntent(MapIntent.EnterMapScreen) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { + is MapEffect.RefreshPhotoBooth -> { + // TODO: 포토부스 새로고침 로직 구현 + } is MapEffect.RefreshCurrentLocation -> { - locationTrackingMode = LocationTrackingMode.Follow + if (LocationPermissionManager.hasLocationPermission(context)) { + locationTrackingMode = LocationTrackingMode.Follow + } else { + viewModel.store.onIntent(MapIntent.RequestLocationPermission) + } + } + + is MapEffect.OpenDirectionBottomSheet -> { + if (LocationPermissionManager.hasLocationPermission(context)) { + viewModel.store.onIntent(MapIntent.OpenDirectionBottomSheet) + } else { + viewModel.store.onIntent(MapIntent.RequestLocationPermission) + } } is MapEffect.ShowToastMessage -> { - Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + NekiToast(context).showToast(sideEffect.message) } is MapEffect.MoveCameraToPosition -> { coroutineScope.launch { @@ -82,21 +126,26 @@ fun MapRoute( } } is MapEffect.MoveDirectionApp -> { + val startLatitude = sideEffect.startLatitude + val startLongitude = sideEffect.startLongitude + val endLatitude = sideEffect.endLatitude + val endLongitude = sideEffect.endLongitude + when (sideEffect.app) { DirectionApp.GOOGLE_MAP -> { DirectionHelper.moveAppOrStore( context = context, - url = "google.navigation:q=37.5256372,126.8862648(${context.getPlaceName(37.5256372, 126.8861924, "도착지")})&mode=w", + url = "google.navigation:q=$endLatitude,$endLongitude&mode=w", packageName = sideEffect.app.packageName, ) } DirectionApp.NAVER_MAP -> { - val startName = context.getPlaceName(37.5270539, 126.8862648, "출발지") - val destName = context.getPlaceName(37.5256372, 126.8861924, "도착지") + val startName = context.getPlaceName(startLatitude, startLongitude, "출발지") + val destName = context.getPlaceName(endLatitude, endLongitude, "도착지") DirectionHelper.moveAppOrStore( context = context, - url = "nmap://route/walk?slat=37.5270539&slng=126.8862648&sname=$startName&dlat=37.5256372&dlng=126.8861924&dname=$destName", + url = "nmap://route/walk?slat=$startLatitude&slng=$startLongitude&sname=$startName&dlat=$endLatitude&dlng=$endLongitude&dname=$destName", packageName = sideEffect.app.packageName, ) } @@ -104,12 +153,21 @@ fun MapRoute( DirectionApp.KAKAO_MAP -> { DirectionHelper.moveAppOrStore( context = context, - url = "kakaomap://route?sp=37.5270539,126.8862648&ep=37.5256372,126.8861924&by=FOOT", + url = "kakaomap://route?sp=$startLatitude,$startLongitude&ep=$endLatitude,$endLongitude&by=FOOT", packageName = sideEffect.app.packageName, ) } } } + + is MapEffect.NavigateToAppSettings -> { + navigateToAppSettings(context) + } + + is MapEffect.RequestLocationPermission -> { + previousShouldShowRationale = LocationPermissionManager.shouldShowLocationRationale(activity) + locationPermissionLauncher.launch(LocationPermissionManager.LOCATION_PERMISSIONS) + } } } @@ -120,21 +178,6 @@ fun MapRoute( onLocationTrackingModeChange = { locationTrackingMode = it }, cameraPositionState = cameraPositionState, ) - - if (uiState.isShowInfoDialog) { - WarningDialog( - content = "가까운 네컷 사진 브랜드는\n1km 기준으로 표시돼요.", - onDismissRequest = { viewModel.store.onIntent(MapIntent.ClickCloseInfoIcon) }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) - } - - if (uiState.isShowDirectionBottomSheet) { - DirectionBottomSheet( - onDismissRequest = { viewModel.store.onIntent(MapIntent.CloseDirectionBottomSheet) }, - onClickDirectionItem = { viewModel.store.onIntent(MapIntent.ClickDirectionItem(it)) }, - ) - } } @OptIn(ExperimentalNaverMapApi::class) @@ -156,6 +199,7 @@ fun MapScreen( val mapUiSettings = remember { MapUiSettings( isLocationButtonEnabled = false, + isZoomControlEnabled = false, ) } @@ -173,17 +217,20 @@ fun MapScreen( onLocationTrackingModeChange(it) } }, + onLocationChange = { location -> + onIntent(MapIntent.UpdateCurrentLocation(location.latitude, location.longitude)) + }, ) { - uiState.nearbyBrands.forEachIndexed { index, brandInfo -> + uiState.nearbyPhotoBooths.forEach { brandInfo -> val isFocused = uiState.focusedMarkerPosition == (brandInfo.latitude to brandInfo.longitude) - BrandMarker( + PhotoBoothMarker( keys = arrayOf("$isFocused"), latitude = brandInfo.latitude, longitude = brandInfo.longitude, brandImageRes = brandInfo.brandImageRes, isFocused = isFocused, onClick = { - onIntent(MapIntent.ClickBrandMarker(latitude = brandInfo.latitude, longitude = brandInfo.longitude)) + onIntent(MapIntent.ClickPhotoBoothMarker(latitude = brandInfo.latitude, longitude = brandInfo.longitude)) }, ) } @@ -191,42 +238,70 @@ fun MapScreen( AnchoredDraggablePanel( brands = uiState.brands, - nearbyBrands = uiState.nearbyBrands, - dragValue = uiState.dragState, - onDragValueChanged = { onIntent(MapIntent.ChangeDragValue(it)) }, + nearbyBrands = uiState.nearbyPhotoBooths, + dragLevel = uiState.dragLevel, + onDragLevelChanged = { onIntent(MapIntent.ChangeDragLevel(it)) }, onClickInfoIcon = { onIntent(MapIntent.ClickInfoIcon) }, isCurrentLocation = locationTrackingMode == LocationTrackingMode.Follow, - onClickCurrentLocation = { - onLocationTrackingModeChange(LocationTrackingMode.Follow) - }, + onClickCurrentLocation = { onIntent(MapIntent.ClickCurrentLocation) }, onClickBrand = { onIntent(MapIntent.ClickBrand(it)) }, - onClickNearBrand = { onIntent(MapIntent.ClickNearBrand(it)) }, + onClickNearBrand = { onIntent(MapIntent.ClickNearPhotoBooth(it)) }, ) - if (uiState.dragState == DragValue.Top) { + if ((uiState.dragLevel == DragLevel.FIRST || uiState.dragLevel == DragLevel.SECOND) && + locationTrackingMode != LocationTrackingMode.Follow + ) { + MapRefreshChip( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 12.dp), + onClick = { onIntent(MapIntent.ClickRefresh) }, + ) + } + + if (uiState.dragLevel == DragLevel.THIRD) { ToMapChip( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp), onClick = { onIntent(MapIntent.ClickToMapChip) }, ) - } else if (uiState.dragState == DragValue.Invisible && uiState.selectedBrandInfo != null) { - PanelInvisibleContent( - brandInfo = uiState.selectedBrandInfo, + } else if (uiState.dragLevel == DragLevel.INVISIBLE && uiState.selectedPhotoBoothInfo != null) { + PhotoBoothDetailCard( + brandInfo = uiState.selectedPhotoBoothInfo, modifier = Modifier.align(Alignment.BottomCenter), isCurrentLocation = locationTrackingMode == LocationTrackingMode.Follow, onClickCurrentLocation = { onIntent(MapIntent.ClickCurrentLocation) }, - onClickCloseCard = { onIntent(MapIntent.ClickCloseBrandCard) }, - onClickDirection = { onIntent(MapIntent.ClickDirection(uiState.selectedBrandInfo.latitude, uiState.selectedBrandInfo.longitude)) }, + onClickCloseCard = { onIntent(MapIntent.ClickClosePhotoBoothCard) }, + onClickDirection = { onIntent(MapIntent.ClickDirection) }, ) } } -} -@Preview -@Composable -private fun MapScreenPreview() { - NekiTheme { - MapScreen() + if (uiState.isShowInfoDialog) { + WarningDialog( + content = "가까운 네컷 사진 브랜드는\n1km 기준으로 표시돼요.", + onDismissRequest = { onIntent(MapIntent.ClickCloseInfoIcon) }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) + } + + if (uiState.isShowDirectionBottomSheet) { + DirectionBottomSheet( + onDismissRequest = { onIntent(MapIntent.CloseDirectionBottomSheet) }, + onClickDirectionItem = { onIntent(MapIntent.ClickDirectionItem(it)) }, + ) + } + + if (uiState.isShowLocationPermissionDialog) { + SingleButtonAlertDialog( + title = "위치 권한", + content = "설정에서 위치 권한을 허용해주세요.", + buttonText = "확인", + onDismissRequest = { onIntent(MapIntent.DismissLocationPermissionDialog) }, + onClick = { onIntent(MapIntent.ConfirmLocationPermissionDialog) }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) } } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt index 9bb1214c2..8682e4e6a 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt @@ -27,91 +27,116 @@ class MapViewModel @Inject constructor() : ViewModel() { postSideEffect: (MapEffect) -> Unit, ) { when (intent) { - MapIntent.EnterMapScreen -> { - loadBrands(reduce) - } - - MapIntent.ClickCurrentLocation -> { - postSideEffect(MapEffect.RefreshCurrentLocation) - } - - MapIntent.ClickInfoIcon -> { - reduce { copy(isShowInfoDialog = true) } - } - - MapIntent.ClickCloseInfoIcon -> { - reduce { copy(isShowInfoDialog = false) } - } - - MapIntent.ClickToMapChip -> { - reduce { copy(dragState = DragValue.Bottom) } - } - - is MapIntent.ClickBrand -> { - reduce { - copy( - brands = state.brands.map { brand -> - if (brand == intent.brand) { - brand.copy(isChecked = !brand.isChecked) - } else { - brand - } - }.toImmutableList(), - ) - } - } - - is MapIntent.ClickNearBrand -> { - reduce { - copy( - dragState = DragValue.Invisible, - selectedBrandInfo = intent.brandInfo, - focusedMarkerPosition = intent.brandInfo.latitude to intent.brandInfo.longitude, - ) - } - postSideEffect(MapEffect.MoveCameraToPosition(intent.brandInfo.latitude, intent.brandInfo.longitude)) - } - - MapIntent.ClickCloseBrandCard -> { - reduce { copy(dragState = DragValue.Center, focusedMarkerPosition = Pair(0.0, 0.0), selectedBrandInfo = null) } + MapIntent.EnterMapScreen -> loadBrands(reduce) + MapIntent.ClickRefresh -> postSideEffect(MapEffect.RefreshPhotoBooth) + is MapIntent.UpdateCurrentLocation -> reduce { copy(currentLocation = intent.latitude to intent.longitude) } + MapIntent.ClickCurrentLocation -> postSideEffect(MapEffect.RefreshCurrentLocation) + MapIntent.ClickInfoIcon -> reduce { copy(isShowInfoDialog = true) } + MapIntent.ClickCloseInfoIcon -> reduce { copy(isShowInfoDialog = false) } + MapIntent.ClickToMapChip -> reduce { copy(dragLevel = DragLevel.FIRST) } + is MapIntent.ClickBrand -> handleClickBrand(state, intent, reduce) + is MapIntent.ClickNearPhotoBooth -> handleClickNearBrand(intent, reduce, postSideEffect) + MapIntent.ClickClosePhotoBoothCard -> reduce { + copy( + dragLevel = DragLevel.SECOND, + focusedMarkerPosition = null, + selectedPhotoBoothInfo = null, + ) } - - MapIntent.CloseDirectionBottomSheet -> { - reduce { copy(isShowDirectionBottomSheet = false) } + MapIntent.OpenDirectionBottomSheet -> reduce { copy(isShowDirectionBottomSheet = true) } + MapIntent.CloseDirectionBottomSheet -> reduce { copy(isShowDirectionBottomSheet = false) } + is MapIntent.ClickDirectionItem -> handleClickDirectionItem(state, intent, reduce, postSideEffect) + is MapIntent.ChangeDragLevel -> reduce { copy(dragLevel = intent.dragLevel) } + is MapIntent.ClickPhotoBoothMarker -> handleClickBrandMarker(state, intent, reduce, postSideEffect) + MapIntent.ClickDirection -> postSideEffect(MapEffect.OpenDirectionBottomSheet) + MapIntent.RequestLocationPermission -> postSideEffect(MapEffect.RequestLocationPermission) + MapIntent.ShowLocationPermissionDialog -> reduce { copy(isShowLocationPermissionDialog = true) } + MapIntent.DismissLocationPermissionDialog -> reduce { copy(isShowLocationPermissionDialog = false) } + MapIntent.ConfirmLocationPermissionDialog -> { + reduce { copy(isShowLocationPermissionDialog = false) } + postSideEffect(MapEffect.NavigateToAppSettings) } + } + } - is MapIntent.ClickDirectionItem -> { - reduce { copy(isShowDirectionBottomSheet = false) } - postSideEffect(MapEffect.MoveDirectionApp(intent.app)) - } + private fun handleClickBrand( + state: MapState, + intent: MapIntent.ClickBrand, + reduce: (MapState.() -> MapState) -> Unit, + ) { + reduce { + copy( + brands = state.brands.map { brand -> + if (brand == intent.brand) { + brand.copy(isChecked = !brand.isChecked) + } else { + brand + } + }.toImmutableList(), + ) + } + } - is MapIntent.ChangeDragValue -> { - reduce { copy(dragState = intent.dragValue) } - } + private fun handleClickNearBrand( + intent: MapIntent.ClickNearPhotoBooth, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + reduce { + copy( + dragLevel = DragLevel.INVISIBLE, + selectedPhotoBoothInfo = intent.brandInfo, + focusedMarkerPosition = intent.brandInfo.latitude to intent.brandInfo.longitude, + ) + } + postSideEffect(MapEffect.MoveCameraToPosition(intent.brandInfo.latitude, intent.brandInfo.longitude)) + } - is MapIntent.ClickBrandMarker -> { - val selectedBrand = state.nearbyBrands.find { it.latitude == intent.latitude && it.longitude == intent.longitude } - reduce { - copy( - dragState = DragValue.Invisible, - focusedMarkerPosition = intent.latitude to intent.longitude, - selectedBrandInfo = selectedBrand, - ) - } - postSideEffect(MapEffect.MoveCameraToPosition(intent.latitude, intent.longitude)) - } + private fun handleClickDirectionItem( + state: MapState, + intent: MapIntent.ClickDirectionItem, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + reduce { copy(isShowDirectionBottomSheet = false) } + if (state.currentLocation == null) { + postSideEffect(MapEffect.ShowToastMessage("현재 위치를 가져올 수 없습니다.")) + return + } + state.selectedPhotoBoothInfo?.let { brandInfo -> + postSideEffect( + MapEffect.MoveDirectionApp( + app = intent.app, + startLatitude = state.currentLocation.first, + startLongitude = state.currentLocation.second, + endLatitude = brandInfo.latitude, + endLongitude = brandInfo.longitude, + ), + ) + } + } - is MapIntent.ClickDirection -> { - reduce { copy(isShowDirectionBottomSheet = true) } - } + private fun handleClickBrandMarker( + state: MapState, + intent: MapIntent.ClickPhotoBoothMarker, + reduce: (MapState.() -> MapState) -> Unit, + postSideEffect: (MapEffect) -> Unit, + ) { + val selectedBrand = state.nearbyPhotoBooths.find { it.latitude == intent.latitude && it.longitude == intent.longitude } + reduce { + copy( + dragLevel = DragLevel.INVISIBLE, + focusedMarkerPosition = intent.latitude to intent.longitude, + selectedPhotoBoothInfo = selectedBrand, + ) } + postSideEffect(MapEffect.MoveCameraToPosition(intent.latitude, intent.longitude)) } private fun loadBrands(reduce: (MapState.() -> MapState) -> Unit) { viewModelScope.launch { reduce { copy(isLoading = true) } - // TODO: 서버 API 연동 시 교체 val brands = persistentListOf( Brand(isChecked = false, brandName = "인생네컷", brandImageRes = R.drawable.icon_life_four_cut), Brand(isChecked = false, brandName = "포토그레이", brandImageRes = R.drawable.icon_photogray), @@ -121,7 +146,6 @@ class MapViewModel @Inject constructor() : ViewModel() { Brand(isChecked = false, brandName = "포토시그니처", brandImageRes = R.drawable.icon_photo_signature), ) - // TODO: 서버 API 연동 시 교체 // 중심: 37.5270539, 126.8862648 주변 100m 이내 val nearbyBrands = persistentListOf( BrandInfo( @@ -137,7 +161,7 @@ class MapViewModel @Inject constructor() : ViewModel() { brandImageRes = R.drawable.icon_photogray, branchName = "가산역점", distance = "38m", - latitude = 37.5268, + latitude = 37.5248, longitude = 126.8867, ), BrandInfo( @@ -146,23 +170,23 @@ class MapViewModel @Inject constructor() : ViewModel() { branchName = "마리오점", distance = "52m", latitude = 37.5274, - longitude = 126.8858, + longitude = 126.8828, ), BrandInfo( brandName = "하루필름", brandImageRes = R.drawable.icon_haru_film, branchName = "W몰점", distance = "65m", - latitude = 37.5266, - longitude = 126.8859, + latitude = 37.5166, + longitude = 126.8659, ), BrandInfo( brandName = "플랜비스튜디오", brandImageRes = R.drawable.icon_planb_studio, branchName = "대륭포스트점", distance = "72m", - latitude = 37.5276, - longitude = 126.8869, + latitude = 37.5176, + longitude = 126.8969, ), BrandInfo( brandName = "포토시그니처", @@ -194,7 +218,7 @@ class MapViewModel @Inject constructor() : ViewModel() { copy( isLoading = false, brands = brands, - nearbyBrands = nearbyBrands, + nearbyPhotoBooths = nearbyBrands, ) } } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt index a7ed170ef..443dcc068 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/AnchoredDraggablePanel.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,7 +38,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource @@ -54,7 +52,7 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Brand import com.neki.android.core.model.BrandInfo import com.neki.android.core.ui.compose.VerticalSpacer -import com.neki.android.feature.map.impl.DragValue +import com.neki.android.feature.map.impl.DragLevel import com.neki.android.feature.map.impl.const.MapConst import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -64,9 +62,9 @@ import kotlin.math.roundToInt internal fun AnchoredDraggablePanel( brands: ImmutableList = persistentListOf(), nearbyBrands: ImmutableList = persistentListOf(), - dragValue: DragValue = DragValue.Bottom, + dragLevel: DragLevel = DragLevel.FIRST, isCurrentLocation: Boolean = false, - onDragValueChanged: (DragValue) -> Unit = {}, + onDragLevelChanged: (DragLevel) -> Unit = {}, onClickCurrentLocation: () -> Unit = {}, onClickInfoIcon: () -> Unit = {}, onClickBrand: (Brand) -> Unit = {}, @@ -76,52 +74,51 @@ internal fun AnchoredDraggablePanel( val configuration = LocalConfiguration.current val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } - var collapsedHeightPx by remember { mutableIntStateOf(0) } val navigationBarHeightPx = with(density) { WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding().toPx() } - val bottomOffsetPx = remember(collapsedHeightPx, navigationBarHeightPx) { - with(density) { - collapsedHeightPx + - // currentLocationButton (36.dp + 12.dp) - 48.dp.toPx() + - MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT.dp.toPx() + - navigationBarHeightPx - } + val bottomPanelHeightPx = with(density) { + (MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT + + MapConst.PANEL_DRAG_LOCATION_HEIGHT + + MapConst.PANEL_DRAG_LEVEL_FIRST_HEIGHT).dp.toPx() + navigationBarHeightPx + } + val centerPanelHeightPx = with(density) { + (MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT + + MapConst.PANEL_DRAG_LOCATION_HEIGHT + + MapConst.PANEL_DRAG_LEVEL_SECOND_HEIGHT).dp.toPx() + navigationBarHeightPx } - var isProgrammaticTransition by remember { mutableStateOf(false) } - val state = remember(collapsedHeightPx) { + val state = remember { val anchors = DraggableAnchors { - DragValue.Bottom at screenHeightPx - bottomOffsetPx - DragValue.Center at screenHeightPx * 0.3f - DragValue.Top at screenHeightPx * 0.05f - DragValue.Invisible at screenHeightPx + DragLevel.FIRST at screenHeightPx - bottomPanelHeightPx + DragLevel.SECOND at screenHeightPx - centerPanelHeightPx + DragLevel.THIRD at screenHeightPx * 0.05f + DragLevel.INVISIBLE at screenHeightPx } AnchoredDraggableState( - initialValue = DragValue.Bottom, + initialValue = DragLevel.FIRST, anchors = anchors, positionalThreshold = { distance -> distance * 0.5f }, velocityThreshold = { with(density) { 100.dp.toPx() } }, snapAnimationSpec = tween(), decayAnimationSpec = splineBasedDecay(density), confirmValueChange = { newValue -> - newValue != DragValue.Invisible || isProgrammaticTransition + newValue != DragLevel.INVISIBLE || isProgrammaticTransition }, ) } LaunchedEffect(state.settledValue) { - if (state.settledValue != dragValue) { - onDragValueChanged(state.settledValue) + if (state.settledValue != dragLevel) { + onDragLevelChanged(state.settledValue) } } - LaunchedEffect(dragValue) { + LaunchedEffect(dragLevel) { isProgrammaticTransition = true - state.animateTo(dragValue) + state.animateTo(dragLevel) isProgrammaticTransition = false } @@ -130,9 +127,9 @@ internal fun AnchoredDraggablePanel( .fillMaxSize() .offset { val currentOffset = state.requireOffset() - val shouldConstrainOffset = state.currentValue == DragValue.Bottom && !isProgrammaticTransition + val shouldConstrainOffset = state.currentValue == DragLevel.FIRST && !isProgrammaticTransition val constrainedOffset = if (shouldConstrainOffset) { - currentOffset.coerceAtMost(state.anchors.positionOf(DragValue.Bottom)) + currentOffset.coerceAtMost(state.anchors.positionOf(DragLevel.FIRST)) } else { currentOffset } @@ -147,15 +144,13 @@ internal fun AnchoredDraggablePanel( CurrentLocationButton( modifier = Modifier .padding(start = 20.dp, bottom = 12.dp) - .alpha(alpha = if (dragValue == DragValue.Top) 0f else 1f), + .alpha(alpha = if (dragLevel == DragLevel.THIRD) 0f else 1f), isActiveCurrentLocation = isCurrentLocation, onClick = onClickCurrentLocation, ) AnchoredPanelContent( brands = brands, nearbyBrands = nearbyBrands, - dragValue = dragValue, - onCollapsedHeightMeasured = { collapsedHeightPx = it }, onClickInfoIcon = onClickInfoIcon, onClickBrand = onClickBrand, onClickNearBrand = onClickNearBrand, @@ -168,25 +163,10 @@ internal fun AnchoredDraggablePanel( internal fun AnchoredPanelContent( brands: ImmutableList = persistentListOf(), nearbyBrands: ImmutableList = persistentListOf(), - dragValue: DragValue = DragValue.Bottom, - onCollapsedHeightMeasured: (Int) -> Unit = {}, onClickInfoIcon: () -> Unit = {}, onClickBrand: (Brand) -> Unit = {}, onClickNearBrand: (BrandInfo) -> Unit = {}, ) { - val density = LocalDensity.current - val configuration = LocalConfiguration.current - val screenHeightDp = configuration.screenHeightDp.dp - - /** 패널 외부 상단 현위치 버튼 영역 **/ - val additionalHeightPx = with(density) { (24.dp + 12.dp).toPx().toInt() } - - val extraBottomPadding = when (dragValue) { - DragValue.Center -> screenHeightDp * 0.3f - DragValue.Top -> screenHeightDp * 0.05f - else -> 0.dp - } - Column( modifier = Modifier .fillMaxSize() @@ -197,12 +177,7 @@ internal fun AnchoredPanelContent( ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { - val newHeight = it.size.height + additionalHeightPx - onCollapsedHeightMeasured(newHeight) - }, + modifier = Modifier.fillMaxWidth(), ) { BottomSheetDragHandle() Text( @@ -252,7 +227,7 @@ internal fun AnchoredPanelContent( .weight(1f) .padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = extraBottomPadding), + contentPadding = PaddingValues(bottom = MapConst.BOTTOM_NAVIGATION_BAR_HEIGHT.dp), ) { items(nearbyBrands) { brandInfo -> HorizontalBrandItem( diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt new file mode 100644 index 000000000..c7105c66d --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/CloseButton.kt @@ -0,0 +1,52 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun CloseButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .buttonShadow() + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.white, + ) + .clickable(onClick = onClick) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = null, + tint = NekiTheme.colorScheme.gray800, + ) + } +} + +@ComponentPreview +@Composable +private fun CloseButtonPreview() { + NekiTheme { + CloseButton() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt new file mode 100644 index 000000000..e86aff9c5 --- /dev/null +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/MapRefreshChip.kt @@ -0,0 +1,68 @@ +package com.neki.android.feature.map.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun MapRefreshChip( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .buttonShadow() + .clip(CircleShape) + .clickableSingle(onClick = onClick) + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.white, + ) + .border( + width = 1.dp, + shape = CircleShape, + color = NekiTheme.colorScheme.gray100, + ) + .padding(horizontal = 13.09.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_rotation), + contentDescription = null, + tint = NekiTheme.colorScheme.primary400, + ) + Text( + text = "현 위치에서 탐색", + style = NekiTheme.typography.body14SemiBold, + color = NekiTheme.colorScheme.gray800, + ) + } +} + +@ComponentPreview +@Composable +private fun MapRefreshChipPreview() { + NekiTheme { + MapRefreshChip() + } +} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandCard.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothCard.kt similarity index 97% rename from feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandCard.kt rename to feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothCard.kt index 69d36a12d..f2b7d7d21 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandCard.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothCard.kt @@ -30,7 +30,7 @@ import com.neki.android.core.ui.compose.HorizontalSpacer import com.neki.android.core.ui.compose.VerticalSpacer @Composable -internal fun BrandCard( +internal fun PhotoBoothCard( brand: BrandInfo, modifier: Modifier = Modifier, onClickDirection: () -> Unit = {}, @@ -100,9 +100,9 @@ internal fun BrandCard( @ComponentPreview @Composable -private fun BrandCardPreview() { +private fun PhotoBoothCardPreview() { NekiTheme { - BrandCard( + PhotoBoothCard( brand = BrandInfo( brandName = "인생네컷", brandImageRes = R.drawable.icon_life_four_cut, diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PanelInvisibleContent.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailCard.kt similarity index 59% rename from feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PanelInvisibleContent.kt rename to feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailCard.kt index 737ff6d0e..2e77e685d 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PanelInvisibleContent.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothDetailCard.kt @@ -1,31 +1,22 @@ package com.neki.android.feature.map.impl.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.Column import androidx.compose.foundation.layout.Row 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.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.R -import com.neki.android.core.designsystem.modifier.buttonShadow import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.BrandInfo import com.neki.android.core.ui.compose.VerticalSpacer @Composable -internal fun PanelInvisibleContent( +internal fun PhotoBoothDetailCard( brandInfo: BrandInfo, modifier: Modifier = Modifier, isCurrentLocation: Boolean = false, @@ -47,46 +38,21 @@ internal fun PanelInvisibleContent( isActiveCurrentLocation = isCurrentLocation, onClick = onClickCurrentLocation, ) - BrandCardCloseButton(onClick = onClickCloseCard) + CloseButton(onClick = onClickCloseCard) } VerticalSpacer(12.dp) - BrandCard( + PhotoBoothCard( brand = brandInfo, onClickDirection = onClickDirection, ) } } -@Composable -internal fun BrandCardCloseButton( - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, -) { - Box( - modifier = modifier - .buttonShadow() - .background( - shape = CircleShape, - color = NekiTheme.colorScheme.white, - ) - .clickable(onClick = onClick) - .padding(8.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = ImageVector.vectorResource(R.drawable.icon_close), - contentDescription = null, - tint = NekiTheme.colorScheme.gray800, - ) - } -} - @ComponentPreview @Composable -private fun PanelInvisibleContentPreview() { +private fun PhotoBoothDetailCardPreview() { NekiTheme { - PanelInvisibleContent( + PhotoBoothDetailCard( brandInfo = BrandInfo( brandName = "인생네컷", brandImageRes = R.drawable.icon_life_four_cut, diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandMarker.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt similarity index 91% rename from feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandMarker.kt rename to feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt index a5f1f2179..e1b41f377 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/BrandMarker.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/PhotoBoothMarker.kt @@ -25,7 +25,7 @@ import com.naver.maps.map.compose.MarkerComposable import com.naver.maps.map.compose.rememberUpdatedMarkerState import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.R -import com.neki.android.core.designsystem.modifier.dropdownShadow +import com.neki.android.core.designsystem.modifier.pinShadow import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_BACKGROUND_RADIUS import com.neki.android.feature.map.impl.const.MapConst.FOCUSED_MARKER_IMAGE_RADIUS @@ -40,7 +40,7 @@ import com.neki.android.feature.map.impl.const.MapConst.MARKER_TRIANGLE_WIDTH @OptIn(ExperimentalNaverMapApi::class) @Composable -internal fun BrandMarker( +internal fun PhotoBoothMarker( vararg keys: String, latitude: Double, longitude: Double, @@ -61,7 +61,7 @@ internal fun BrandMarker( true }, ) { - BrandMarkerContent( + PhotoBoothMarkerContent( brandImageRes = brandImageRes, isFocused = isFocused, ) @@ -69,7 +69,7 @@ internal fun BrandMarker( } @Composable -internal fun BrandMarkerContent( +internal fun PhotoBoothMarkerContent( modifier: Modifier = Modifier, brandImageRes: Int, isFocused: Boolean = false, @@ -93,13 +93,10 @@ internal fun BrandMarkerContent( Box( modifier = Modifier .size(bodySize.dp) - .dropdownShadow( - color = Color.Black.copy(alpha = 0.38f), + .pinShadow( shape = RoundedCornerShape( if (isFocused) FOCUSED_MARKER_BACKGROUND_RADIUS.dp else MARKER_BACKGROUND_RADIUS.dp, ), - offsetY = 1.18.dp, - blurRadius = 2.55.dp, ), ) @@ -112,9 +109,9 @@ internal fun BrandMarkerContent( height = MARKER_TRIANGLE_HEIGHT.dp, ), ) { - val shadowColor = Color.Black.copy(alpha = 0.38f) - val offsetY = 1.18.dp.toPx() - val blurRadius = 2.55.dp.toPx() + val shadowColor = Color.Black.copy(alpha = 0.4f) + val offsetY = 1.dp.toPx() + val blurRadius = 2.5.dp.toPx() val path = Path().apply { moveTo(0f, 0f) @@ -186,9 +183,9 @@ internal fun BrandMarkerContent( @ComponentPreview @Composable -private fun BrandMarkerContentPreview() { +private fun PhotoBoothMarkerPreview() { NekiTheme { - BrandMarkerContent( + PhotoBoothMarkerContent( brandImageRes = R.drawable.icon_life_four_cut, ) } @@ -196,9 +193,9 @@ private fun BrandMarkerContentPreview() { @ComponentPreview @Composable -private fun BrandMarkerContentSelectedPreview() { +private fun PhotoBoothMarkerSelectedPreview() { NekiTheme { - BrandMarkerContent( + PhotoBoothMarkerContent( brandImageRes = R.drawable.icon_life_four_cut, isFocused = true, ) diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt index 0142dc01d..0914429f9 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/component/ToMapChip.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.buttonShadow import com.neki.android.core.designsystem.modifier.clickableSingle import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -27,9 +28,13 @@ internal fun ToMapChip( ) { Row( modifier = modifier + .buttonShadow() .clip(CircleShape) .clickableSingle(onClick = onClick) - .background(shape = CircleShape, color = NekiTheme.colorScheme.gray800) + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.gray800, + ) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -51,6 +56,6 @@ internal fun ToMapChip( @Composable private fun ToMapChipPreview() { NekiTheme { - ToMapChip(modifier = Modifier.padding(8.dp)) + ToMapChip() } } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt index 348c103db..2c4f77433 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt @@ -1,7 +1,10 @@ package com.neki.android.feature.map.impl.const internal object MapConst { - internal const val BOTTOM_NAVIGATION_BAR_HEIGHT = 72 + internal const val BOTTOM_NAVIGATION_BAR_HEIGHT = 52 + internal const val PANEL_DRAG_LOCATION_HEIGHT = 48 + internal const val PANEL_DRAG_LEVEL_FIRST_HEIGHT = 96 + internal const val PANEL_DRAG_LEVEL_SECOND_HEIGHT = 218 // 마커 internal const val MARKER_BACKGROUND_RADIUS = 20