diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml
index 5bb75fef..8854d504 100644
--- a/.github/workflows/android-pull-request-ci.yml
+++ b/.github/workflows/android-pull-request-ci.yml
@@ -43,6 +43,12 @@ jobs:
run: |
echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties
+ - name: Access Kakao KAKAO_NATIVE_APP_KEY
+ env:
+ KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ run: |
+ echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> local.properties
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts
index bd42f1a5..6ab76de2 100644
--- a/Near/app/build.gradle.kts
+++ b/Near/app/build.gradle.kts
@@ -32,10 +32,14 @@ android {
)
buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요
+ buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY"))
+ manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "")
}
debug {
buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요
+ buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY"))
+ manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "")
}
}
compileOptions {
@@ -88,6 +92,12 @@ dependencies {
implementation(libs.navigation.compose)
// Serialization
implementation(libs.kotlin.serialization.json)
+ // DataStore
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.datastore.core)
+
+ // Kakao Module
+ implementation(libs.v2.all)
}
fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey)
diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml
index 08b99e2f..b0ae0a68 100644
--- a/Near/app/src/main/AndroidManifest.xml
+++ b/Near/app/src/main/AndroidManifest.xml
@@ -15,6 +15,25 @@
android:supportsRtl="true"
android:theme="@style/Theme.Near"
tools:targetApi="31">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
=
+ try {
+ val token =
+ if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
+ loginWithKakaoTalk()
+ } else {
+ loginWithKakaoAccount()
+ }
+
+ if (token.isNotEmpty()) {
+ Result.success(token)
+ } else {
+ Result.failure(Exception("사용자가 로그인을 취소했습니다"))
+ }
+ } catch (exception: Exception) {
+ Result.failure(exception)
+ }
+
+ private suspend fun loginWithKakaoTalk(): String =
+ suspendCancellableCoroutine { continuation ->
+ UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
+ when {
+ error != null -> {
+ if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
+ continuation.resume("")
+ } else {
+ UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError ->
+ handleLoginResult(retryToken, retryError, continuation)
+ }
+ }
+ }
+ token != null -> continuation.resume(token.accessToken)
+ else -> continuation.resume("")
+ }
+ }
+ }
+
+ private suspend fun loginWithKakaoAccount(): String =
+ suspendCancellableCoroutine { continuation ->
+ UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
+ handleLoginResult(token, error, continuation)
+ }
+ }
+
+ private fun handleLoginResult(
+ token: OAuthToken?,
+ error: Throwable?,
+ continuation: CancellableContinuation,
+ ) {
+ when {
+ error != null -> {
+ if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
+ continuation.resume("")
+ } else {
+ continuation.resumeWith(Result.failure(error))
+ }
+ }
+ token != null -> continuation.resume(token.accessToken)
+ else -> continuation.resume("")
+ }
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt
new file mode 100644
index 00000000..9d2bb534
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt
@@ -0,0 +1,20 @@
+package com.alarmy.near.data.datasource
+
+import com.alarmy.near.model.ProviderType
+
+/**
+ * 소셜 로그인 데이터 소스 인터페이스
+ * Strategy 패턴으로 각 소셜 플랫폼별로 구현
+ */
+interface SocialLoginDataSource {
+ /**
+ * 지원하는 소셜 로그인 타입
+ */
+ val supportedType: ProviderType
+
+ /**
+ * 소셜 로그인 수행
+ * Context는 생성자에서 주입받아 사용
+ */
+ suspend fun login(): Result
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt
new file mode 100644
index 00000000..1944d12a
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt
@@ -0,0 +1,32 @@
+package com.alarmy.near.data.datasource
+
+
+import com.alarmy.near.model.ProviderType
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 소셜 로그인 프로세서
+ * Strategy 패턴으로 동적으로 로그인 방식 선택
+ */
+@Singleton
+class SocialLoginProcessor
+ @Inject
+ constructor(
+ private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>,
+ ) {
+ /**
+ * 소셜 로그인 처리
+ * @param providerType 로그인 제공자 타입
+ * @return 로그인 결과
+ */
+ suspend fun processLogin(
+ providerType: ProviderType,
+ ): Result {
+ val dataSource =
+ socialLoginDataSources.find { it.supportedType == providerType }
+ ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}"))
+
+ return dataSource.login()
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt
new file mode 100644
index 00000000..4a6fb2b8
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt
@@ -0,0 +1,17 @@
+package com.alarmy.near.data.di
+
+import com.alarmy.near.data.datasource.KakaoDataSource
+import com.alarmy.near.data.datasource.SocialLoginDataSource
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface DataSourceModule {
+ @Binds
+ @IntoSet
+ abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt
new file mode 100644
index 00000000..5f73bfb6
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt
@@ -0,0 +1,25 @@
+package com.alarmy.near.data.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+// DataStore 확장 프로퍼티
+private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences")
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+ @Provides
+ @Singleton
+ fun provideDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.dataStore
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt
index 3dff6205..5d73c37f 100644
--- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt
+++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt
@@ -1,5 +1,7 @@
package com.alarmy.near.data.di
+import com.alarmy.near.data.repository.AuthRepository
+import com.alarmy.near.data.repository.AuthRepositoryImpl
import com.alarmy.near.data.repository.DefaultFriendRepository
import com.alarmy.near.data.repository.ExampleRepository
import com.alarmy.near.data.repository.ExampleRepositoryImpl
@@ -20,4 +22,8 @@ interface RepositoryModule {
@Binds
@Singleton
abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt
new file mode 100644
index 00000000..13f5eaa0
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt
@@ -0,0 +1,110 @@
+package com.alarmy.near.data.local.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 토큰 저장소 DataStore 관리 클래스
+ * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당
+ */
+@Singleton
+class TokenPreferences
+ @Inject
+ constructor(
+ private val dataStore: DataStore,
+ ) {
+ private val accessTokenKey = stringPreferencesKey("access_token")
+ private val refreshTokenKey = stringPreferencesKey("refresh_token")
+ private val expiresAtKey = longPreferencesKey("expires_at")
+
+ /**
+ * 두 토큰 동시 저장
+ */
+ suspend fun saveTokens(
+ accessToken: String,
+ refreshToken: String?,
+ expiresIn: Long? = null,
+ ) {
+ dataStore.edit { preferences ->
+ preferences[accessTokenKey] = accessToken
+ refreshToken?.let {
+ preferences[refreshTokenKey] = it
+ }
+ expiresIn?.let {
+ val expiresAt = System.currentTimeMillis() + (it * 1000)
+ preferences[expiresAtKey] = expiresAt
+ }
+ }
+ }
+
+ /**
+ * 액세스 토큰 조회
+ */
+ suspend fun getAccessToken(): String? = dataStore.data.first()[accessTokenKey]
+
+ /**
+ * 리프레시 토큰 조회
+ */
+ suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshTokenKey]
+
+ /**
+ * 토큰 존재 여부 확인
+ */
+ suspend fun hasValidTokens(): Boolean {
+ val prefs = dataStore.data.first()
+ val accessToken = prefs[accessTokenKey]
+ if (accessToken.isNullOrBlank()) return false
+
+ val expiresAt = prefs[expiresAtKey]
+ val isExpired = expiresAt == null || System.currentTimeMillis() >= expiresAt
+ return !isExpired
+ }
+
+ /**
+ * 토큰 만료 여부 확인
+ */
+ suspend fun isTokenExpired(): Boolean {
+ val expiresAt = dataStore.data.first()[expiresAtKey] ?: return true
+ return System.currentTimeMillis() >= expiresAt
+ }
+
+ /**
+ * 토큰 만료 시간 조회
+ */
+ suspend fun getTokenExpiresAt(): Long? = dataStore.data.first()[expiresAtKey]
+
+ /**
+ * 모든 토큰 삭제
+ */
+ suspend fun clearAllTokens() {
+ dataStore.edit { preferences ->
+ preferences.remove(accessTokenKey)
+ preferences.remove(refreshTokenKey)
+ preferences.remove(expiresAtKey)
+ }
+ }
+
+ /**
+ * 액세스 토큰 관찰
+ */
+ fun observeAccessToken() =
+ dataStore.data.map { preferences ->
+ preferences[accessTokenKey]
+ }
+
+ /**
+ * 로그인 상태 관찰
+ */
+ fun observeLoginStatus() =
+ dataStore.data.map { preferences ->
+ !preferences[accessTokenKey].isNullOrBlank()
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt
new file mode 100644
index 00000000..ded14dc6
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt
@@ -0,0 +1,32 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.model.ProviderType
+import kotlinx.coroutines.flow.Flow
+
+interface AuthRepository {
+ // 소셜 로그인 수행
+ suspend fun performSocialLogin(providerType: ProviderType): Result
+
+ /**
+ * 소셜 로그인 수행 (토큰 직접 전달)
+ */
+ suspend fun socialLogin(
+ accessToken: String,
+ providerType: ProviderType,
+ ): Result
+
+ // 로그아웃 수행
+ suspend fun logout()
+
+ // 로그인 상태 확인
+ suspend fun isLoggedIn(): Boolean
+
+ // 현재 사용자 토큰 가져오기
+ suspend fun getCurrentUserToken(): String?
+
+ // 로그인 상태 확인
+ fun observeLoginStatus(): Flow
+
+ // 토큰 갱신
+ suspend fun refreshToken(): Boolean
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 00000000..43f51f80
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,115 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.data.datasource.SocialLoginProcessor
+import com.alarmy.near.model.ProviderType
+import com.alarmy.near.network.auth.TokenManager
+import com.alarmy.near.network.request.SocialLoginRequest
+import com.alarmy.near.network.service.AuthService
+import kotlinx.coroutines.flow.Flow
+import retrofit2.HttpException
+import javax.inject.Inject
+
+class AuthRepositoryImpl
+ @Inject
+ constructor(
+ private val authService: AuthService,
+ private val socialLoginProcessor: SocialLoginProcessor,
+ private val tokenManager: TokenManager,
+ ) : AuthRepository {
+ override suspend fun performSocialLogin(providerType: ProviderType): Result =
+ try {
+ val result = socialLoginProcessor.processLogin(providerType)
+
+ if (result.isSuccess) {
+ val accessToken = result.getOrThrow()
+ socialLogin(accessToken, providerType)
+ } else {
+ Result.failure(
+ createLoginException(
+ providerType = providerType,
+ errorMessage = result.exceptionOrNull()?.message,
+ defaultMessage = "로그인에 실패했습니다",
+ ),
+ )
+ }
+ } catch (exception: Exception) {
+ Result.failure(
+ createLoginException(
+ providerType = providerType,
+ errorMessage = exception.message,
+ defaultMessage = "로그인 중 오류가 발생했습니다",
+ ),
+ )
+ }
+
+ override suspend fun socialLogin(
+ accessToken: String,
+ providerType: ProviderType,
+ ): Result =
+ try {
+ val request =
+ SocialLoginRequest(
+ accessToken = accessToken,
+ providerType = providerType.name,
+ )
+
+ val response = authService.socialLogin(request)
+
+ // 토큰 저장
+ val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt)
+ tokenManager.saveTokens(
+ accessToken = response.accessToken,
+ refreshToken = response.refreshTokenInfo?.token,
+ expiresIn = expiresIn,
+ )
+
+ Result.success(Unit)
+ } catch (exception: HttpException) {
+ val errorMessage = getHttpErrorMessage(exception.code())
+ Result.failure(Exception(errorMessage))
+ } catch (exception: Exception) {
+ val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다"
+ Result.failure(Exception(errorMessage))
+ }
+
+ override suspend fun logout() {
+ tokenManager.clearAllTokens()
+ }
+
+ override suspend fun isLoggedIn(): Boolean =
+ try {
+ tokenManager.hasValidToken()
+ } catch (exception: Exception) {
+ false
+ }
+
+ override suspend fun getCurrentUserToken(): String? =
+ try {
+ tokenManager.getAccessToken()
+ } catch (exception: Exception) {
+ null
+ }
+
+ override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus()
+
+ override suspend fun refreshToken(): Boolean = tokenManager.refreshToken()
+
+ private fun createLoginException(
+ providerType: ProviderType,
+ errorMessage: String?,
+ defaultMessage: String,
+ ): Exception {
+ val finalMessage = errorMessage ?: "$providerType $defaultMessage"
+ return Exception(finalMessage)
+ }
+
+ // TODO 추후 에러 메시지 변경
+ private fun getHttpErrorMessage(httpCode: Int): String =
+ when (httpCode) {
+ 400 -> "잘못된 요청입니다"
+ 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요."
+ 403 -> "접근이 거부되었습니다"
+ 500 -> "서버에 문제가 발생했습니다"
+ else -> "로그인 중 오류가 발생했습니다"
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt
new file mode 100644
index 00000000..e38df6cd
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt
@@ -0,0 +1,8 @@
+package com.alarmy.near.model
+
+data class LoginResult(
+ val isSuccess: Boolean,
+ val accessToken: String? = null,
+ val refreshToken: String? = null,
+ val errorMessage: String? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt
new file mode 100644
index 00000000..9d4f0fbe
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt
@@ -0,0 +1,6 @@
+package com.alarmy.near.model
+
+enum class ProviderType {
+ KAKAO,
+ ETC,
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt
new file mode 100644
index 00000000..fb6d01ee
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt
@@ -0,0 +1,55 @@
+package com.alarmy.near.network.auth
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 토큰 인증자
+ * 401 에러 발생 시 토큰 갱신을 시도하고 원래 요청을 재시도
+ */
+@Singleton
+class TokenAuthenticator
+@Inject
+constructor(
+ private val tokenManager: TokenManager,
+) : Authenticator {
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ // 401 에러가 아니면 null 반환 (인증 시도하지 않음)
+ if (response.code != 401) {
+ return null
+ }
+
+ // runBlocking을 사용하여 suspend 함수 호출
+ return runBlocking {
+ try {
+ // 토큰 갱신 시도
+ val refreshSuccess = tokenManager.refreshToken()
+
+ // 토큰 갱신 실패 시 null 반환 (재로그인 필요)
+ if (!refreshSuccess) {
+ return@runBlocking null
+ }
+
+ // 새로운 토큰으로 원래 요청 재시도
+ val newToken = tokenManager.getAccessToken()
+
+ if (newToken != null) {
+ response.request
+ .newBuilder()
+ .header("Authorization", "Bearer $newToken")
+ .build()
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
index e2e99566..225e547e 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
@@ -1,21 +1,51 @@
package com.alarmy.near.network.auth
-import com.alarmy.near.BuildConfig
+import com.alarmy.near.network.model.AuthEndpoint
+import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
+import okhttp3.Request
import okhttp3.Response
import javax.inject.Inject
+import javax.inject.Singleton
+/**
+ * 토큰 인터셉터
+ * Authorization 헤더를 자동으로 추가하고 인증이 필요없는 요청은 제외
+ */
+@Singleton
class TokenInterceptor
- @Inject
- constructor() : Interceptor {
- override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
- return chain.proceed(
- request =
- request
- .newBuilder()
- .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}")
- .build(),
- )
+@Inject
+constructor(
+ private val tokenManager: TokenManager,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ if (isAuthExcludedRequest(originalRequest)) {
+ return chain.proceed(originalRequest)
+ }
+
+ // 현재 토큰을 헤더에 추가
+ val token = runBlocking { tokenManager.getAccessToken() }
+ val requestWithAuth = if (token != null) {
+ originalRequest
+ .newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ } else {
+ originalRequest
}
+
+ // 요청 실행 (401 에러는 Authenticator에서 처리)
+ return chain.proceed(requestWithAuth)
+ }
+
+ /**
+ * 인증이 필요없는 요청인지 확인
+ */
+ private fun isAuthExcludedRequest(request: Request): Boolean {
+ val url = request.url.toString()
+ return AuthEndpoint.excludedPaths.any { url.contains(it) }
}
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt
new file mode 100644
index 00000000..29411a4e
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt
@@ -0,0 +1,125 @@
+package com.alarmy.near.network.auth
+
+import com.alarmy.near.data.local.datastore.TokenPreferences
+import com.alarmy.near.network.request.TokenRefreshRequest
+import com.alarmy.near.network.service.AuthService
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * 토큰 관리자
+ * 토큰 저장, 조회, 갱신을 담당하는 중앙 관리 클래스
+ * Provider 패턴으로 순환 참조 해결
+ */
+@Singleton
+class TokenManager
+@Inject
+constructor(
+ private val tokenPreferences: TokenPreferences,
+ private val authServiceProvider: Provider, // Provider로 지연 주입
+) {
+
+ private val refreshMutex = Mutex()
+
+ /**
+ * 현재 액세스 토큰 가져오기
+ */
+ suspend fun getAccessToken(): String? {
+ return tokenPreferences.getAccessToken()
+ }
+
+ /**
+ * 토큰 갱신 시도
+ * @return 갱신 성공 여부
+ */
+ suspend fun refreshToken(): Boolean {
+ return refreshMutex.withLock {
+ try {
+ val refreshToken = tokenPreferences.getRefreshToken() ?: return false
+
+ // Provider를 통해 AuthService 가져오기 (지연 주입)
+ val authService = authServiceProvider.get()
+
+ // 토큰 갱신 API 호출
+ val request = TokenRefreshRequest(refreshToken = refreshToken)
+ val response = authService.renewToken(request)
+
+ // 새로운 토큰 저장
+ val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt)
+
+ tokenPreferences.saveTokens(
+ accessToken = response.accessToken,
+ refreshToken = response.refreshTokenInfo?.token,
+ expiresIn = expiresIn,
+ )
+
+ true
+ } catch (e: Exception) {
+ // 갱신 실패 시 토큰 삭제
+ tokenPreferences.clearAllTokens()
+ false
+ }
+ }
+ }
+
+ /**
+ * 토큰이 유효한지 확인
+ */
+ suspend fun hasValidToken(): Boolean {
+ return tokenPreferences.hasValidTokens()
+ }
+
+ /**
+ * 모든 토큰 삭제
+ */
+ suspend fun clearAllTokens() {
+ tokenPreferences.clearAllTokens()
+ }
+
+ /**
+ * 로그인 상태 관찰
+ */
+ fun observeLoginStatus(): Flow {
+ return tokenPreferences.observeLoginStatus()
+ }
+
+ /**
+ * 토큰 저장
+ */
+ suspend fun saveTokens(
+ accessToken: String,
+ refreshToken: String?,
+ expiresIn: Long?,
+ ) {
+ tokenPreferences.saveTokens(
+ accessToken = accessToken,
+ refreshToken = refreshToken,
+ expiresIn = expiresIn,
+ )
+ }
+
+ /**
+ * 만료 시간 계산 (초 단위)
+ * java.time 패키지 사용으로 스레드 안전성 보장
+ */
+ fun calculateExpiresIn(expiresAtString: String?): Long? {
+ return expiresAtString?.let { expiresAt ->
+ try {
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+ val expiresAtTime = LocalDateTime.parse(expiresAt, formatter)
+ val expiresAtMillis = expiresAtTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ (expiresAtMillis - System.currentTimeMillis()) / 1000 // 초 단위로 변환
+ } catch (e: DateTimeParseException) {
+ null
+ }
+ }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
index de4e1e8d..e6663ac9 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
@@ -1,6 +1,7 @@
package com.alarmy.near.network.di
import com.alarmy.near.BuildConfig
+import com.alarmy.near.network.auth.TokenAuthenticator
import com.alarmy.near.network.auth.TokenInterceptor
import dagger.Module
import dagger.Provides
@@ -32,11 +33,13 @@ object NetworkModule {
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
tokenInterceptor: TokenInterceptor,
+ tokenAuthenticator: TokenAuthenticator,
): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(tokenInterceptor)
+ .authenticator(tokenAuthenticator)
.build()
@Provides
diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt
index a6ef4802..6324fd18 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt
@@ -1,5 +1,6 @@
package com.alarmy.near.network.di
+import com.alarmy.near.network.service.AuthService
import com.alarmy.near.network.service.FriendService
import dagger.Module
import dagger.Provides
@@ -11,6 +12,10 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
+ @Provides
+ @Singleton
+ fun provideAuthService(retrofit: Retrofit): AuthService = retrofit.create(AuthService::class.java)
+
@Provides
@Singleton
fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt
new file mode 100644
index 00000000..a19479ed
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt
@@ -0,0 +1,21 @@
+package com.alarmy.near.network.model
+
+/**
+ * 인증 관련 API 엔드포인트
+ *
+ * 인증이 필요없는 경로들을 관리하여 TokenInterceptor에서
+ * Authorization 헤더 추가를 제외할 수 있습니다.
+ */
+enum class AuthEndpoint(val path: String) {
+ SOCIAL_LOGIN("/auth/social"),
+ TOKEN_RENEW("/auth/renew"),
+ ;
+
+ companion object {
+ /**
+ * 인증이 필요없는 모든 경로 목록
+ * TokenInterceptor에서 이 경로들은 Authorization 헤더를 추가하지 않습니다
+ */
+ val excludedPaths: List = entries.map { it.path }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt
new file mode 100644
index 00000000..56a78fee
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt
@@ -0,0 +1,18 @@
+package com.alarmy.near.network.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * 소셜 로그인 요청 데이터 클래스
+ *
+ * @param accessToken 소셜 로그인 AccessToken
+ * @param providerType 소셜 로그인 Provider 타입 (KAKAO, APPLE 등)
+ */
+@Serializable
+data class SocialLoginRequest(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("providerType")
+ val providerType: String,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt
new file mode 100644
index 00000000..2edc96ef
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt
@@ -0,0 +1,11 @@
+package com.alarmy.near.network.request
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 토큰 갱신 요청 모델
+ */
+@Serializable
+data class TokenRefreshRequest(
+ val refreshToken: String
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt
new file mode 100644
index 00000000..c8ff5fd4
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt
@@ -0,0 +1,21 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+// 소셜 로그인 응답 데이터 클래스
+@Serializable
+data class LoginResponse(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("refreshTokenInfo")
+ val refreshTokenInfo: RefreshTokenInfo? = null,
+)
+
+@Serializable
+data class RefreshTokenInfo(
+ @SerialName("token")
+ val token: String,
+ @SerialName("expiresAt")
+ val expiresAt: String, // "2025-08-20 03:02:07" 형식
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt
new file mode 100644
index 00000000..b4b0dc73
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt
@@ -0,0 +1,15 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * 토큰 갱신 응답 모델
+ */
+@Serializable
+data class TokenRefreshResponse(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("refreshTokenInfo")
+ val refreshTokenInfo: RefreshTokenInfo? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt
new file mode 100644
index 00000000..587008ea
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt
@@ -0,0 +1,25 @@
+package com.alarmy.near.network.service
+
+import com.alarmy.near.network.request.SocialLoginRequest
+import com.alarmy.near.network.request.TokenRefreshRequest
+import com.alarmy.near.network.response.LoginResponse
+import com.alarmy.near.network.response.TokenRefreshResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+/**
+ * 인증 관련 API 서비스
+ */
+interface AuthService {
+ // 소셜 로그인 API 호출
+ @POST("/auth/social")
+ suspend fun socialLogin(
+ @Body request: SocialLoginRequest,
+ ): LoginResponse
+
+ // 토큰 갱신 API 호출
+ @POST("/auth/renew")
+ suspend fun renewToken(
+ @Body request: TokenRefreshRequest,
+ ): TokenRefreshResponse
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
new file mode 100644
index 00000000..10990fba
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
@@ -0,0 +1,164 @@
+package com.alarmy.near.presentation.feature.login
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+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.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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 androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.alarmy.near.R
+import com.alarmy.near.model.ProviderType
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@Composable
+internal fun LoginRoute(
+ onNavigateToHome: () -> Unit,
+ viewModel: LoginViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.loginSuccessEvent.collect {
+ onNavigateToHome()
+ }
+ }
+
+ LoginScreen(
+ uiState = uiState,
+ onLoginClick = { providerType ->
+ viewModel.performLogin(providerType)
+ },
+ )
+}
+
+@Composable
+fun LoginScreen(
+ uiState: LoginUiState,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(NearTheme.colors.WHITE_FFFFFF)
+ .systemBarsPadding(),
+ ) {
+ LoginIntroductionSection(modifier = Modifier.fillMaxWidth())
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // 소셜 로그인 버튼
+ SocialLoginButtons(
+ isLoading = uiState.isLoading,
+ onLoginClick = onLoginClick,
+ )
+ }
+}
+
+@Composable
+private fun LoginIntroductionSection(modifier: Modifier = Modifier) {
+ Spacer(modifier = Modifier.height(LoginScreenConstants.TOP_SPACING.dp))
+
+ Image(
+ modifier = modifier.size(LoginScreenConstants.LOGO_SIZE.dp),
+ alignment = Alignment.Center,
+ painter = painterResource(R.drawable.img_40_character),
+ contentDescription = stringResource(R.string.near_logo),
+ )
+
+ Image(
+ modifier = modifier.wrapContentSize(Alignment.Center),
+ alignment = Alignment.Center,
+ painter = painterResource(R.drawable.ic_near_logo_title),
+ contentDescription = stringResource(R.string.near_logo_title),
+ )
+
+ Spacer(modifier = Modifier.size(LoginScreenConstants.DESCRIPTION_SPACING.dp))
+
+ Text(
+ modifier = modifier.wrapContentSize(Alignment.Center),
+ text = stringResource(R.string.login_near_description),
+ style = NearTheme.typography.B1_16_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+}
+
+@Composable
+private fun ColumnScope.SocialLoginButtons(
+ isLoading: Boolean,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ // 카카오 로그인 버튼
+ SocialLoginButton(
+ isEnabled = !isLoading,
+ providerType = ProviderType.KAKAO,
+ buttonResource = R.drawable.btn_kakao_login,
+ contentDescription = stringResource(R.string.login_kakao_login_button_text),
+ onLoginClick = onLoginClick,
+ )
+
+ Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp))
+}
+
+@Composable
+private fun ColumnScope.SocialLoginButton(
+ isEnabled: Boolean,
+ providerType: ProviderType,
+ buttonResource: Int,
+ contentDescription: String,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ Image(
+ modifier =
+ Modifier
+ .wrapContentSize()
+ .align(Alignment.CenterHorizontally)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ enabled = isEnabled,
+ ) {
+ onLoginClick(providerType)
+ },
+ painter = painterResource(buttonResource),
+ contentDescription = contentDescription,
+ )
+}
+
+private object LoginScreenConstants {
+ const val TOP_SPACING = 170
+ const val LOGO_SIZE = 160
+ const val DESCRIPTION_SPACING = 12
+ const val BOTTOM_SPACING = 96
+}
+
+@Preview(showBackground = true)
+@Composable
+fun LoginScreenPreview() {
+ NearTheme {
+ LoginScreen(
+ uiState = LoginUiState(),
+ onLoginClick = { },
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt
new file mode 100644
index 00000000..5f9a9c68
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt
@@ -0,0 +1,67 @@
+package com.alarmy.near.presentation.feature.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.alarmy.near.data.repository.AuthRepository
+import com.alarmy.near.model.ProviderType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel
+ @Inject
+ constructor(
+ private val authRepository: AuthRepository,
+ ) : ViewModel() {
+ // UI 상태 관리
+ private val _uiState = MutableStateFlow(LoginUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // 에러 이벤트 관리
+ private val _errorEvent = Channel()
+ val errorEvent = _errorEvent.receiveAsFlow()
+
+ // 로그인 성공 이벤트 관리
+ private val _loginSuccessEvent = Channel()
+ val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
+
+ /**
+ * 소셜 로그인 수행
+ */
+ fun performLogin(providerType: ProviderType) {
+ viewModelScope.launch {
+ updateLoadingState(isLoading = true)
+
+ authRepository.performSocialLogin(providerType)
+ .onSuccess {
+ updateLoadingState(isLoading = false)
+ _loginSuccessEvent.send(Unit)
+ }
+ .onFailure { exception ->
+ updateLoadingState(isLoading = false)
+ _errorEvent.send(exception)
+ }
+ }
+ }
+
+ /**
+ * 로딩 상태 업데이트
+ */
+ private fun updateLoadingState(isLoading: Boolean) {
+ _uiState.value = _uiState.value.copy(isLoading = isLoading)
+ }
+ }
+
+/**
+ * 로그인 화면 UI 상태
+ */
+data class LoginUiState(
+ val isLoading: Boolean = false,
+ val hasError: Boolean = false,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt
new file mode 100644
index 00000000..f2d70ce7
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt
@@ -0,0 +1,28 @@
+package com.alarmy.near.presentation.feature.login.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.alarmy.near.presentation.feature.login.LoginRoute
+import kotlinx.serialization.Serializable
+
+@Serializable
+object RouteLogin
+
+// 로그인 화면으로 이동하는 확장 함수
+fun NavController.navigateToLogin(navOptions: NavOptions? = null) {
+ navigate(RouteLogin, navOptions)
+}
+
+// 로그인 화면 NavGraph 정의
+fun NavGraphBuilder.loginNavGraph(
+ onNavigateToHome: () -> Unit,
+ onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
+) {
+ composable {
+ LoginRoute(
+ onNavigateToHome = onNavigateToHome,
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt
index 94f9e995..3b7ad4b9 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt
@@ -9,6 +9,9 @@ import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToF
import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph
import com.alarmy.near.presentation.feature.home.navigation.RouteHome
import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph
+import com.alarmy.near.presentation.feature.home.navigation.navigateToHome
+import com.alarmy.near.presentation.feature.login.navigation.RouteLogin
+import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph
@Composable
internal fun NearNavHost(
@@ -22,14 +25,21 @@ internal fun NearNavHost(
NavHost(
modifier = modifier,
navController = navController,
- startDestination = RouteHome,
+ startDestination = RouteLogin,
) {
- friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = {
- navController.popBackStack()
- })
- friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = {
- navController.popBackStack()
- })
+ // 로그인 화면 NavGraph
+ loginNavGraph(
+ onShowErrorSnackBar = onShowSnackbar,
+ onNavigateToHome = {
+ navController.navigateToHome(
+ navOptions = androidx.navigation.navOptions {
+ popUpTo(RouteLogin) { inclusive = true }
+ }
+ )
+ }
+ )
+
+ // 홈 화면 NavGraph
homeNavGraph(
onShowErrorSnackBar = onShowSnackbar,
onContactClick = { contactId ->
@@ -39,5 +49,21 @@ internal fun NearNavHost(
onAlarmClick = {},
onAddContactClick = {},
)
+
+ // 친구 프로필 화면 NavGraph
+ friendProfileNavGraph(
+ onShowErrorSnackBar = onShowSnackbar,
+ onClickBackButton = {
+ navController.popBackStack()
+ }
+ )
+
+ // 친구 프로필 편집 화면 NavGraph
+ friendProfileEditorNavGraph(
+ onShowErrorSnackBar = onShowSnackbar,
+ onClickBackButton = {
+ navController.popBackStack()
+ }
+ )
}
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
index b6167e82..5737bacc 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
@@ -1,9 +1,13 @@
package com.alarmy.near.presentation.ui.theme
+import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
val LocalCustomColors =
staticCompositionLocalOf {
@@ -30,6 +34,20 @@ fun NearTheme(
LocalCustomTypography provides Typography,
content = content,
)
+
+/* 스크린에서 상태바 아이콘 색상
+* */
+ val view = LocalView.current
+// val isDarkTheme = isSystemInDarkTheme() 시스템 다크 모드 여부를 Boolean으로 반환
+ val isDarkTheme = false
+
+ SideEffect {
+ if (!view.isInEditMode) {
+ val window = (view.context as Activity).window
+ val insetsController = WindowCompat.getInsetsController(window, view)
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
+ }
+ }
}
object NearTheme {
diff --git a/Near/app/src/main/res/drawable/btn_kakao_login.xml b/Near/app/src/main/res/drawable/btn_kakao_login.xml
new file mode 100644
index 00000000..c477cf1f
--- /dev/null
+++ b/Near/app/src/main/res/drawable/btn_kakao_login.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title.xml b/Near/app/src/main/res/drawable/ic_near_logo_title.xml
new file mode 100644
index 00000000..4a457eac
--- /dev/null
+++ b/Near/app/src/main/res/drawable/ic_near_logo_title.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml
index 3606877d..f6daf58a 100644
--- a/Near/app/src/main/res/values/strings.xml
+++ b/Near/app/src/main/res/values/strings.xml
@@ -7,6 +7,12 @@
뒤로 가기
메뉴
+
+ Near 로고
+ Near 타이틀
+ 소중한 사람들과 더 가까워지는 시간
+ 카카오 로그인 버튼
+
MY
이번달 챙길 사람
diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml
index 9c13f8fc..21b73514 100644
--- a/Near/gradle/libs.versions.toml
+++ b/Near/gradle/libs.versions.toml
@@ -25,9 +25,15 @@ navigationVersion = "2.9.2"
kotlinSerializationVersion = "1.9.0"
# OkHttp
okHttp = "5.1.0"
+# DataStore
+datastorePreferences = "1.1.7"
+datastoreCore = "1.1.7"
+#Kakao
+v2All = "2.21.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -55,6 +61,8 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationVersion" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttp" }
retrofit-kotlin-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitVersion" }
+androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" }
+v2-all = { module = "com.kakao.sdk:v2-all", version.ref = "v2All" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/Near/settings.gradle.kts b/Near/settings.gradle.kts
index 2d06f9e7..de36a1fc 100644
--- a/Near/settings.gradle.kts
+++ b/Near/settings.gradle.kts
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") }
}
}