diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 2c75525..29a2f09 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -12,18 +12,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
- cache: gradle
+ - uses: actions/checkout@v4
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
- - name: Create local.properties
- run: echo "API_KEY=ci_dummy_key" > local.properties
+ - name: Create local.properties
+ run: echo "API_KEY=ci_dummy_key" > local.properties
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
- - name: Build with Gradle
- run: ./gradlew build
\ No newline at end of file
+ - name: Create secrets.properties
+ run: |
+ echo "BASE_URL=${{ secrets.BASE_URL }}" > secrets.properties
+ echo "KAKAO_NATIVE_APP_KEY=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> secrets.properties
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build with Gradle
+ run: ./gradlew build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2dc2a2a..c6030df 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,14 +1,42 @@
+import java.util.Properties
+
plugins {
alias(libs.plugins.hyunjung.cherrydan.android.application.compose)
alias(libs.plugins.hyunjung.cherrydan.jvm.ktor)
alias(libs.plugins.mapsplatform.secrets.plugin)
}
+secrets {
+ defaultPropertiesFileName = "secrets.properties"
+}
+
android {
namespace = "com.hyunjung.cherrydan"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField(
+ "String",
+ "BASE_URL",
+ "\"https://cherrydan.com\""
+ )
+
+ val secretsFile = file("${rootProject.projectDir}/secrets.properties")
+ val kakaoKey = if (secretsFile.exists()) {
+ val properties = Properties()
+ properties.load(secretsFile.inputStream())
+ properties.getProperty("KAKAO_NATIVE_APP_KEY", "")
+ } else {
+ ""
+ }
+
+ manifestPlaceholders["kakaoScheme"] = "kakao$kakaoKey"
+ manifestPlaceholders["kakaoKey"] = kakaoKey
+ }
+
+ buildFeatures {
+ buildConfig = true
}
}
@@ -57,14 +85,21 @@ dependencies {
api(libs.play.feature.delivery)
api(libs.play.review)
+ implementation(projects.feature.auth)
+ implementation(projects.feature.home)
+ implementation(projects.feature.notification)
+ implementation(projects.feature.search)
+
implementation(projects.core.presentation.designsystem)
implementation(projects.core.presentation.ui)
implementation(projects.core.domain)
implementation(projects.core.data)
implementation(projects.core.database)
+ implementation(projects.core.network)
+ implementation(projects.core.common)
- implementation(projects.feature.auth)
- implementation(projects.feature.home)
- implementation(projects.feature.notification)
- implementation(projects.feature.search)
+ // Kakao SDK
+ implementation(libs.kakao.auth)
+ implementation(libs.kakao.common)
+ implementation(libs.kakao.user)
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5772739..c13dbef 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/hyunjung/cherrydan/CherrydanApplication.kt b/app/src/main/java/com/hyunjung/cherrydan/CherrydanApplication.kt
new file mode 100644
index 0000000..861f72c
--- /dev/null
+++ b/app/src/main/java/com/hyunjung/cherrydan/CherrydanApplication.kt
@@ -0,0 +1,44 @@
+package com.hyunjung.cherrydan
+
+import android.app.Application
+import com.hyunjung.core.data.di.dataModule
+import com.hyunjung.core.network.di.networkModule
+import com.hyunjung.feature.auth.di.authViewModelModule
+import com.kakao.sdk.common.KakaoSdk
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+import timber.log.Timber
+
+class CherrydanApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Timber 초기화 (가장 먼저)
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+
+ // 카카오 앱키 확인
+ val kakaoAppKey = BuildConfig.KAKAO_NATIVE_APP_KEY
+ Timber.d("Kakao App Key: $kakaoAppKey")
+
+ if (kakaoAppKey.isEmpty()) {
+ Timber.e("Kakao App Key가 설정되지 않았습니다!")
+ }
+
+ // Kakao SDK 초기화
+ KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY)
+
+ // Koin 초기화
+ startKoin {
+ androidContext(this@CherrydanApplication)
+ modules(
+ authViewModelModule,
+ dataModule,
+ networkModule
+ // 다른 모듈들도 여기에 추가
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/hyunjung/cherrydan/MainActivity.kt b/app/src/main/java/com/hyunjung/cherrydan/MainActivity.kt
index 3280dbb..ae7ac0f 100644
--- a/app/src/main/java/com/hyunjung/cherrydan/MainActivity.kt
+++ b/app/src/main/java/com/hyunjung/cherrydan/MainActivity.kt
@@ -1,13 +1,29 @@
package com.hyunjung.cherrydan
+import android.content.pm.PackageManager
import android.os.Bundle
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.hyunjung.cherrydan.navigation.AppNavigation
import com.hyunjung.core.presentation.designsystem.CherrydanTheme
+import com.hyunjung.feature.auth.login.LogInScreenRoot
+import com.hyunjung.feature.auth.splash.SplashScreen
import com.hyunjung.feature.home.navigation.HomeNavigation
+import timber.log.Timber
+import java.security.MessageDigest
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,16 +31,54 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
CherrydanTheme {
- HomeNavigation()
+ var isLoggedIn by remember { mutableStateOf(false) }
+
+ if (isLoggedIn) {
+ HomeNavigation()
+ } else {
+ val navController = rememberNavController()
+
+ NavHost(
+ navController = navController,
+ startDestination = "splash"
+ ) {
+ composable("splash") {
+ SplashScreen(
+ onSplashFinished = {
+ // TODO: 실제로는 토큰 확인 후 결정
+ // if (hasValidToken) { isLoggedIn = true } else { navigate to login }
+ navController.navigate("login") {
+ popUpTo("splash") { inclusive = true }
+ }
+ }
+ )
+ }
+ composable("login") {
+ LogInScreenRoot(
+ onKakaoLogInClick = { },
+ onNaverLogInClick = { },
+ onGoogleLogInClick = { },
+ onLoginSuccess = { isLoggedIn = true }
+ )
+ }
+ }
+ }
}
}
}
+
+ override fun onResume() {
+ super.onResume()
+ Timber.d("MainActivity onResume called")
+ }
}
@Preview
@Composable
private fun MainActivityPreview() {
CherrydanTheme {
- HomeNavigation()
+ AppNavigation(
+ navController = rememberNavController()
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/hyunjung/cherrydan/navigation/AppNavigation.kt b/app/src/main/java/com/hyunjung/cherrydan/navigation/AppNavigation.kt
new file mode 100644
index 0000000..08a25e3
--- /dev/null
+++ b/app/src/main/java/com/hyunjung/cherrydan/navigation/AppNavigation.kt
@@ -0,0 +1,62 @@
+package com.hyunjung.cherrydan.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.hyunjung.feature.auth.login.LogInScreenRoot
+import com.hyunjung.feature.auth.splash.SplashScreen
+import com.hyunjung.feature.home.navigation.HomeNavigation
+
+@Composable
+fun AppNavigation(
+ navController: NavHostController = rememberNavController()
+) {
+ NavHost(
+ navController = navController,
+ startDestination = "splash"
+ ) {
+ composable("splash") {
+ SplashScreen(
+ onSplashFinished = {
+ navController.navigate("login") {
+ popUpTo("splash") { inclusive = true }
+ }
+ }
+ )
+ }
+ composable("login") {
+ LogInScreenRoot(
+ onKakaoLogInClick = {
+ navController.navigate("home") {
+ popUpTo("login") { inclusive = true }
+ }
+ },
+ onNaverLogInClick = {
+ navController.navigate("home") {
+ popUpTo("login") { inclusive = true }
+ }
+ },
+ onGoogleLogInClick = {
+ navController.navigate("home") {
+ popUpTo("login") { inclusive = true }
+ }
+ },
+ onLoginSuccess = {
+ navController.navigate("home") {
+ popUpTo("login") { inclusive = true }
+ }
+ }
+ )
+ }
+ composable("home") {
+ HomeNavigation()
+ }
+// authGraph(navController)
+//
+// mainGraph(navController)
+//
+// profileGraph(navController)
+ }
+}
\ No newline at end of file
diff --git a/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/BuildTypes.kt b/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/BuildTypes.kt
index 0bfa9a0..6c404ce 100644
--- a/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/BuildTypes.kt
+++ b/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/BuildTypes.kt
@@ -7,7 +7,6 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
-import java.util.Properties
internal fun Project.configureBuildTypes(
commonExtension: CommonExtension<*, *, *, *, *, *>,
@@ -19,43 +18,29 @@ internal fun Project.configureBuildTypes(
}
val apiKey = gradleLocalProperties(rootDir, providers).getProperty("API_KEY")
-
when (extensionType) {
ExtensionType.APPLICATION -> {
extensions.configure {
buildTypes {
debug {
- configureDebugBuildType(
- this@configureBuildTypes,
- apiKey
- )
+ configureDebugBuildType(apiKey)
}
release {
- configureReleaseBuildType(
- this@configureBuildTypes,
- commonExtension,
- apiKey
- )
+ configureReleaseBuildType(commonExtension, apiKey)
}
}
}
+
}
ExtensionType.LIBRARY -> {
extensions.configure {
buildTypes {
debug {
- configureDebugBuildType(
- this@configureBuildTypes,
- apiKey
- )
+ configureDebugBuildType(apiKey)
}
release {
- configureReleaseBuildType(
- this@configureBuildTypes,
- commonExtension,
- apiKey
- )
+ configureReleaseBuildType(commonExtension, apiKey)
}
}
}
@@ -64,40 +49,17 @@ internal fun Project.configureBuildTypes(
}
}
-private fun Project.getBaseUrl(): String {
- val localProperties = gradleLocalProperties(rootDir, providers)
- val localBaseUrl = localProperties.getProperty("BASE_URL")
- if (!localBaseUrl.isNullOrEmpty()) {
- return localBaseUrl
- }
-
- val secretsPropertiesFile = rootProject.file("secrets.properties")
- if (secretsPropertiesFile.exists()) {
- val secretsProperties = Properties()
- secretsProperties.load(secretsPropertiesFile.inputStream())
- val secretsBaseUrl = secretsProperties.getProperty("BASE_URL")
- if (!secretsBaseUrl.isNullOrEmpty()) {
- return secretsBaseUrl
- }
- }
-
- return "https://cherrydan.com"
-}
-
-private fun BuildType.configureDebugBuildType(project: Project, apiKey: String) {
+private fun BuildType.configureDebugBuildType(apiKey: String) {
buildConfigField("String", "API_KEY", "\"$apiKey\"")
- val baseUrl = project.getBaseUrl()
- buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
+ buildConfigField("String", "BASE_URL", "\"https://cherrydan.com\"")
}
private fun BuildType.configureReleaseBuildType(
- project: Project,
commonExtension: CommonExtension<*, *, *, *, *, *>,
apiKey: String
) {
buildConfigField("String", "API_KEY", "\"$apiKey\"")
- val baseUrl = project.getBaseUrl()
- buildConfigField("String", "BASE_URL", "\"$baseUrl\"")
+ buildConfigField("String", "BASE_URL", "\"https://cherrydan.com\"")
isMinifyEnabled = false
proguardFiles(
diff --git a/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/ComposeDependencies.kt b/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/ComposeDependencies.kt
index ef61c76..21ddc06 100644
--- a/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/ComposeDependencies.kt
+++ b/build-logic/convention/src/main/kotlin/com/hyunjung/cherrydan/ComposeDependencies.kt
@@ -7,6 +7,7 @@ import org.gradle.kotlin.dsl.project
fun DependencyHandlerScope.addUiLayerDependencies(project: Project) {
"implementation"(project(":core:presentation:ui"))
"implementation"(project(":core:presentation:designsystem"))
+ "implementation"(project(":core:domain"))
"implementation"(project.libs.findBundle("koin.compose").get())
"implementation"(project.libs.findBundle("compose").get())
diff --git a/core/common/.gitignore b/core/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 0000000..41d259c
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ alias(libs.plugins.hyunjung.cherrydan.jvm.library)
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/hyunjung/core/domain/util/DataError.kt b/core/common/src/main/java/com/hyunjung/core/common/util/DataError.kt
similarity index 90%
rename from core/domain/src/main/java/com/hyunjung/core/domain/util/DataError.kt
rename to core/common/src/main/java/com/hyunjung/core/common/util/DataError.kt
index 84d81b7..f3fb245 100644
--- a/core/domain/src/main/java/com/hyunjung/core/domain/util/DataError.kt
+++ b/core/common/src/main/java/com/hyunjung/core/common/util/DataError.kt
@@ -1,4 +1,4 @@
-package com.hyunjung.core.domain.util
+package com.hyunjung.core.common.util
sealed interface DataError : Error {
enum class Network : DataError {
diff --git a/core/common/src/main/java/com/hyunjung/core/common/util/Error.kt b/core/common/src/main/java/com/hyunjung/core/common/util/Error.kt
new file mode 100644
index 0000000..58a8195
--- /dev/null
+++ b/core/common/src/main/java/com/hyunjung/core/common/util/Error.kt
@@ -0,0 +1,5 @@
+package com.hyunjung.core.common.util
+
+interface Error {
+
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/hyunjung/core/domain/util/Result.kt b/core/common/src/main/java/com/hyunjung/core/common/util/Result.kt
similarity index 55%
rename from core/domain/src/main/java/com/hyunjung/core/domain/util/Result.kt
rename to core/common/src/main/java/com/hyunjung/core/common/util/Result.kt
index a2cf905..9adcd9a 100644
--- a/core/domain/src/main/java/com/hyunjung/core/domain/util/Result.kt
+++ b/core/common/src/main/java/com/hyunjung/core/common/util/Result.kt
@@ -1,8 +1,10 @@
-package com.hyunjung.core.domain.util
+package com.hyunjung.core.common.util
+
sealed interface Result {
data class Success(val data: D) : Result
- data class Error(val error: E) : Result
+ data class Error(val error: E) :
+ Result
}
inline fun Result.map(map: (T) -> R): Result {
@@ -16,4 +18,9 @@ fun Result.asEmptyDataResult(): EmptyDataResult {
return map { Unit }
}
-typealias EmptyDataResult = Result
\ No newline at end of file
+typealias EmptyDataResult = Result
+
+inline fun Result.onSuccess(action: (T) -> Unit): Result {
+ if (this is Result.Success) action(data)
+ return this
+}
\ No newline at end of file
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 4ffba89..0666e7f 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -12,4 +12,6 @@ dependencies {
implementation(projects.core.domain)
implementation(projects.core.database)
+ implementation(projects.core.network)
+ implementation(libs.bundles.koin)
}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/hyunjung/core/data/di/DataModule.kt b/core/data/src/main/java/com/hyunjung/core/data/di/DataModule.kt
new file mode 100644
index 0000000..9a5bd6f
--- /dev/null
+++ b/core/data/src/main/java/com/hyunjung/core/data/di/DataModule.kt
@@ -0,0 +1,17 @@
+package com.hyunjung.core.data.di
+
+import com.hyunjung.core.data.repository.AuthRepositoryImpl
+import com.hyunjung.core.domain.repository.AuthRepository
+import com.hyunjung.core.model.SocialType
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val dataModule = module {
+ single {
+ AuthRepositoryImpl(
+ kakao = get(named(SocialType.KAKAO.name)),
+ authRemoteDataSource = get(),
+ tokenManager = get()
+ )
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/hyunjung/core/data/networking/HttpClientExt.kt b/core/data/src/main/java/com/hyunjung/core/data/networking/HttpClientExt.kt
index f4794f8..fa5675b 100644
--- a/core/data/src/main/java/com/hyunjung/core/data/networking/HttpClientExt.kt
+++ b/core/data/src/main/java/com/hyunjung/core/data/networking/HttpClientExt.kt
@@ -1,8 +1,8 @@
package com.hyunjung.core.data.networking
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
import com.hyunjung.core.data.BuildConfig
-import com.hyunjung.core.domain.util.DataError
-import com.hyunjung.core.domain.util.Result
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
diff --git a/core/data/src/main/java/com/hyunjung/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/java/com/hyunjung/core/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 0000000..c5aa222
--- /dev/null
+++ b/core/data/src/main/java/com/hyunjung/core/data/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,54 @@
+package com.hyunjung.core.data.repository
+
+import android.content.Context
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.common.util.onSuccess
+import com.hyunjung.core.domain.repository.AuthRepository
+import com.hyunjung.core.model.AuthTokens
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.SocialType
+import com.hyunjung.core.network.datasource.AuthRemoteDataSource
+import com.hyunjung.core.network.datasource.SocialAuthDataSource
+import com.hyunjung.core.network.token.TokenManager
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+class AuthRepositoryImpl(
+ private val kakao: SocialAuthDataSource,
+ private val authRemoteDataSource: AuthRemoteDataSource,
+ private val tokenManager: TokenManager
+) : AuthRepository {
+ override fun login(
+ context: Context,
+ socialType: SocialType
+ ): Flow> = flow {
+ emit(
+ when (val socialConnected = connectSocialAccount(context, socialType)) {
+ is Result.Success -> {
+ authRemoteDataSource.loginWithSocial(
+ socialType,
+ socialConnected.data.accessToken
+ )
+ .onSuccess { result ->
+ val (accessToken, refreshToken) = result.tokens
+ tokenManager.saveTokens(accessToken, refreshToken)
+ }
+ }
+
+ is Result.Error -> {
+ Result.Error(socialConnected.error)
+ }
+ })
+ }
+
+ private suspend fun connectSocialAccount(
+ context: Context,
+ type: SocialType
+ ): Result =
+ when (type) {
+ SocialType.KAKAO -> kakao.login(context)
+ SocialType.NAVER -> Result.Error(DataError.Network.UNKNOWN)
+ SocialType.GOOGLE -> Result.Error(DataError.Network.UNKNOWN)
+ }
+}
\ No newline at end of file
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index ce3b3af..e2cb8d4 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -1,7 +1,13 @@
plugins {
- alias(libs.plugins.hyunjung.cherrydan.jvm.library)
+ alias(libs.plugins.hyunjung.cherrydan.android.library)
+}
+
+android{
+ namespace = "com.hyunjung.core.domain"
}
dependencies {
+ api(projects.core.model)
+ api(projects.core.common)
implementation(libs.kotlinx.coroutines.core)
}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/hyunjung/core/domain/repository/AuthRepository.kt b/core/domain/src/main/java/com/hyunjung/core/domain/repository/AuthRepository.kt
new file mode 100644
index 0000000..09e2a4a
--- /dev/null
+++ b/core/domain/src/main/java/com/hyunjung/core/domain/repository/AuthRepository.kt
@@ -0,0 +1,12 @@
+package com.hyunjung.core.domain.repository
+
+import android.content.Context
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.SocialType
+import kotlinx.coroutines.flow.Flow
+
+interface AuthRepository {
+ fun login(context: Context, socialType: SocialType): Flow>
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/hyunjung/core/domain/util/Error.kt b/core/domain/src/main/java/com/hyunjung/core/domain/util/Error.kt
deleted file mode 100644
index 7ac9406..0000000
--- a/core/domain/src/main/java/com/hyunjung/core/domain/util/Error.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.hyunjung.core.domain.util
-
-interface Error {
-
-}
\ No newline at end of file
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index f3bd531..41d259c 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -1,13 +1,3 @@
plugins {
- id("java-library")
- alias(libs.plugins.jetbrains.kotlin.jvm)
-}
-java {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
-}
-kotlin {
- compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
- }
-}
+ alias(libs.plugins.hyunjung.cherrydan.jvm.library)
+}
\ No newline at end of file
diff --git a/core/model/src/main/java/com/hyunjung/core/model/AuthTokens.kt b/core/model/src/main/java/com/hyunjung/core/model/AuthTokens.kt
new file mode 100644
index 0000000..0fa760b
--- /dev/null
+++ b/core/model/src/main/java/com/hyunjung/core/model/AuthTokens.kt
@@ -0,0 +1,11 @@
+package com.hyunjung.core.model
+
+data class AuthTokens(
+ val accessToken: String,
+ val refreshToken: String
+) {
+ init {
+ require(accessToken.isNotBlank()) { "Access token must not be blank" }
+ require(refreshToken.isNotBlank()) { "Refresh token must not be blank" }
+ }
+}
\ No newline at end of file
diff --git a/core/model/src/main/java/com/hyunjung/core/model/LoginResult.kt b/core/model/src/main/java/com/hyunjung/core/model/LoginResult.kt
new file mode 100644
index 0000000..b109de7
--- /dev/null
+++ b/core/model/src/main/java/com/hyunjung/core/model/LoginResult.kt
@@ -0,0 +1,6 @@
+package com.hyunjung.core.model
+
+data class LoginResult(
+ val user: User,
+ val tokens: AuthTokens
+)
\ No newline at end of file
diff --git a/core/model/src/main/java/com/hyunjung/core/model/MyClass.kt b/core/model/src/main/java/com/hyunjung/core/model/MyClass.kt
deleted file mode 100644
index a45c83d..0000000
--- a/core/model/src/main/java/com/hyunjung/core/model/MyClass.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.hyunjung.core.model
-
-class MyClass {
-}
\ No newline at end of file
diff --git a/core/model/src/main/java/com/hyunjung/core/model/SocialType.kt b/core/model/src/main/java/com/hyunjung/core/model/SocialType.kt
new file mode 100644
index 0000000..8828251
--- /dev/null
+++ b/core/model/src/main/java/com/hyunjung/core/model/SocialType.kt
@@ -0,0 +1,5 @@
+package com.hyunjung.core.model
+
+enum class SocialType {
+ KAKAO, NAVER, GOOGLE
+}
\ No newline at end of file
diff --git a/core/model/src/main/java/com/hyunjung/core/model/User.kt b/core/model/src/main/java/com/hyunjung/core/model/User.kt
new file mode 100644
index 0000000..2c37817
--- /dev/null
+++ b/core/model/src/main/java/com/hyunjung/core/model/User.kt
@@ -0,0 +1,8 @@
+package com.hyunjung.core.model
+
+data class User(
+ val userId: Long,
+ val email: String?,
+ val nickname: String?,
+ val profileImage: String?
+)
\ No newline at end of file
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index f030472..2f81179 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -1,13 +1,44 @@
+import java.util.Properties
+
plugins {
alias(libs.plugins.hyunjung.cherrydan.android.library)
alias(libs.plugins.hyunjung.cherrydan.jvm.ktor)
+ alias(libs.plugins.mapsplatform.secrets.plugin)
+}
+
+secrets {
+ defaultPropertiesFileName = "secrets.properties"
}
android {
- namespace = "com.hyunjung.core.network"
+ namespace = "com.hyunjung.cherrydan.core.network"
+
+ defaultConfig {
+ val secrets = Properties().apply {
+ load(file("${rootProject.projectDir}/secrets.properties").inputStream())
+ }
+ buildConfigField("String", "BASE_URL", "\"${secrets["BASE_URL"]}\"")
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
}
dependencies {
- implementation(projects.core.domain)
- implementation(projects.core.data)
+ // Crypto
+ implementation(libs.androidx.security.crypto.ktx)
+
+ implementation(libs.bundles.koin)
+
+ // Timber
+ implementation(libs.timber)
+
+ implementation(projects.core.common)
+ implementation(projects.core.model)
+
+ // Kakao SDK
+ implementation(libs.kakao.auth)
+ implementation(libs.kakao.common)
+ implementation(libs.kakao.user)
}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSource.kt b/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSource.kt
new file mode 100644
index 0000000..93481a4
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSource.kt
@@ -0,0 +1,14 @@
+package com.hyunjung.core.network.datasource
+
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.SocialType
+
+interface AuthRemoteDataSource {
+ suspend fun loginWithSocial(
+ socialType: SocialType,
+ accessToken: String,
+ fcmToken: String? = null,
+ ): Result
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSourceImpl.kt b/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSourceImpl.kt
new file mode 100644
index 0000000..dfdcca7
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/datasource/AuthRemoteDataSourceImpl.kt
@@ -0,0 +1,60 @@
+package com.hyunjung.core.network.datasource
+
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.common.util.map
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.SocialType
+import com.hyunjung.core.network.model.LoginResponse
+import com.hyunjung.core.network.model.SocialLoginRequestResponse
+import com.hyunjung.core.network.model.toDomain
+import com.hyunjung.core.network.resource.AuthResource
+import com.hyunjung.core.network.util.post
+import io.ktor.client.HttpClient
+
+class AuthRemoteDataSourceImpl(
+ private val client: HttpClient
+) : AuthRemoteDataSource {
+ override suspend fun loginWithSocial(
+ socialType: SocialType,
+ accessToken: String,
+ fcmToken: String?,
+ ): Result {
+ return when (socialType) {
+ SocialType.KAKAO -> loginWithKakao(accessToken, fcmToken)
+ SocialType.NAVER -> loginWithNaver(accessToken, fcmToken)
+ SocialType.GOOGLE -> loginWithGoogle(accessToken, fcmToken)
+ }
+ }
+
+ private suspend fun loginWithKakao(
+ accessToken: String,
+ fcmToken: String?
+ ): Result =
+ performSocialLogin(AuthResource.Kakao.Login(), accessToken, fcmToken)
+
+ private suspend fun loginWithNaver(
+ accessToken: String,
+ fcmToken: String?
+ ): Result =
+ performSocialLogin(AuthResource.Naver.Login(), accessToken, fcmToken)
+
+ private suspend fun loginWithGoogle(
+ accessToken: String,
+ fcmToken: String?
+ ): Result =
+ performSocialLogin(AuthResource.Google.Login(), accessToken, fcmToken)
+
+ private suspend inline fun performSocialLogin(
+ resource: T,
+ accessToken: String,
+ fcmToken: String?
+ ): Result = client.post(
+ resource = resource,
+ body = SocialLoginRequestResponse(
+ accessToken = accessToken,
+ fcmToken = fcmToken,
+ deviceType = "android"
+ )
+ ).map { it.toDomain() }
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/datasource/KakaoAuthDataSource.kt b/core/network/src/main/java/com/hyunjung/core/network/datasource/KakaoAuthDataSource.kt
new file mode 100644
index 0000000..1d293d4
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/datasource/KakaoAuthDataSource.kt
@@ -0,0 +1,82 @@
+package com.hyunjung.core.network.datasource
+
+import android.content.Context
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.model.AuthTokens
+import com.kakao.sdk.auth.model.OAuthToken
+import com.kakao.sdk.common.model.ClientError
+import com.kakao.sdk.common.model.ClientErrorCause
+import com.kakao.sdk.user.UserApiClient
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+import kotlin.coroutines.resume
+
+class KakaoAuthDataSource(
+ private val kakaoUserApiClient: UserApiClient
+) : SocialAuthDataSource {
+
+ override suspend fun login(context: Context): Result =
+ suspendCancellableCoroutine { continuation ->
+ Timber.d("카카오 로그인 시작 - ${context::class.java.simpleName}")
+
+ val callback = createKakaoLoginCallback(continuation)
+
+ if (kakaoUserApiClient.isKakaoTalkLoginAvailable(context)) {
+ Timber.d("카카오톡 앱으로 로그인 시도")
+ kakaoUserApiClient.loginWithKakaoTalk(context) { token, error ->
+ if (error != null) {
+ Timber.w(error, "카카오톡 로그인 실패, 계정 로그인으로 fallback")
+ kakaoUserApiClient.loginWithKakaoAccount(
+ context = context,
+ callback = callback
+ )
+ } else {
+ callback(token, null)
+ }
+ }
+ } else {
+ Timber.d("카카오 계정으로 로그인 시도")
+ kakaoUserApiClient.loginWithKakaoAccount(context = context, callback = callback)
+ }
+ }
+
+ private fun resumeIfActive(
+ continuation: CancellableContinuation>,
+ result: Result
+ ) {
+ if (continuation.isActive) {
+ continuation.resume(result)
+ }
+ }
+
+ private fun createKakaoLoginCallback(
+ continuation: CancellableContinuation>,
+ ): (OAuthToken?, Throwable?) -> Unit = { token, error ->
+ when {
+ error != null -> {
+ val result =
+ if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
+ Timber.d("카카오 로그인 취소됨")
+ DataError.Network.UNAUTHORIZED
+ } else {
+ Timber.e(error, "카카오 로그인 실패")
+ DataError.Network.UNKNOWN
+ }
+ resumeIfActive(continuation, Result.Error(result))
+ }
+
+ token != null -> {
+ Timber.d("카카오 로그인 성공")
+ val authTokens = AuthTokens(token.accessToken, token.refreshToken)
+ resumeIfActive(continuation, Result.Success(authTokens))
+ }
+
+ else -> {
+ Timber.e("카카오 로그인 실패: 토큰과 에러가 모두 null")
+ resumeIfActive(continuation, Result.Error(DataError.Network.UNKNOWN))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/datasource/SocialAuthDataSource.kt b/core/network/src/main/java/com/hyunjung/core/network/datasource/SocialAuthDataSource.kt
new file mode 100644
index 0000000..5266e97
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/datasource/SocialAuthDataSource.kt
@@ -0,0 +1,10 @@
+package com.hyunjung.core.network.datasource
+
+import android.content.Context
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.model.AuthTokens
+
+interface SocialAuthDataSource {
+ suspend fun login(context: Context): Result
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/hyunjung/core/network/di/NetworkModule.kt
new file mode 100644
index 0000000..d2780b3
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/di/NetworkModule.kt
@@ -0,0 +1,128 @@
+package com.hyunjung.core.network.di
+
+import com.hyunjung.core.model.SocialType
+import com.hyunjung.core.network.datasource.AuthRemoteDataSource
+import com.hyunjung.core.network.datasource.AuthRemoteDataSourceImpl
+import com.hyunjung.core.network.datasource.KakaoAuthDataSource
+import com.hyunjung.core.network.datasource.SocialAuthDataSource
+import com.hyunjung.core.network.model.TokenResponse
+import com.hyunjung.core.network.token.AuthTokenManager
+import com.hyunjung.core.network.token.TokenManager
+import com.kakao.sdk.user.UserApiClient
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.auth.providers.bearer
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.resources.Resources
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.URLProtocol
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import timber.log.Timber
+
+val networkModule = module {
+ single {
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+ }
+ single { AuthTokenManager(get()) }
+ single { provideHttpClient(get(), get()) }
+ single { UserApiClient.instance }
+
+ single(named(SocialType.KAKAO.name)) {
+ KakaoAuthDataSource(get())
+ }
+
+ // single(named(SocialType.NAVER.name)) {
+ // // NaverAuthDataSource는 아직 구현되지 않았습니다.
+ // throw NotImplementedError("NaverAuthDataSource is not implemented yet.")
+ // }
+ single { AuthRemoteDataSourceImpl(get()) }
+ // single(named(SocialType.GOOGLE.name)) {
+ // // GoogleAuthDataSource는 아직 구현되지 않았습니다.
+ // throw NotImplementedError("GoogleAuthDataSource is not implemented yet.")
+ // }
+}
+
+private fun provideHttpClient(
+ json: Json,
+ tokenManager: TokenManager
+): HttpClient = HttpClient(CIO) {
+
+ defaultRequest {
+ url {
+ protocol = URLProtocol.HTTPS
+ host = "cherrydan.com"
+ }
+ contentType(ContentType.Application.Json)
+ }
+ install(Resources)
+ install(ContentNegotiation) {
+ json(json)
+ }
+
+ install(Logging) {
+ logger = object : Logger {
+ override fun log(message: String) {
+ Timber.d("HTTP: $message")
+ }
+ }
+ level = LogLevel.BODY
+ }
+
+ install(Auth) {
+ bearer {
+ loadTokens {
+ val accessToken = tokenManager.getAccessToken()
+ val refreshToken = tokenManager.getRefreshToken()
+
+ if (accessToken != null && refreshToken != null) {
+ BearerTokens(accessToken, refreshToken)
+ } else {
+ null
+ }
+ }
+
+ // todo : 토큰 갱신 로직 수정 필요
+ refreshTokens {
+ val refreshToken = tokenManager.getRefreshToken()
+ if (refreshToken != null) {
+ try {
+ val response = client.post("/api/auth/refresh") {
+ contentType(ContentType.Application.Json)
+ setBody(mapOf("refreshToken" to refreshToken))
+ }
+
+ val newTokens = response.body()
+ tokenManager.saveTokens(
+ newTokens.accessToken,
+ newTokens.refreshToken
+ )
+
+ BearerTokens(newTokens.accessToken, newTokens.refreshToken)
+ } catch (e: Exception) {
+ Timber.e(e, "토큰 갱신 실패")
+ tokenManager.clearTokens()
+ null
+ }
+ } else {
+ null
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/model/ApiResponse.kt b/core/network/src/main/java/com/hyunjung/core/network/model/ApiResponse.kt
new file mode 100644
index 0000000..4c3bef6
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/model/ApiResponse.kt
@@ -0,0 +1,10 @@
+package com.hyunjung.core.network.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ApiResponse(
+ val code: Int,
+ val message: String,
+ val result: T
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/model/LoginResponse.kt b/core/network/src/main/java/com/hyunjung/core/network/model/LoginResponse.kt
new file mode 100644
index 0000000..fe2bf53
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/model/LoginResponse.kt
@@ -0,0 +1,27 @@
+package com.hyunjung.core.network.model
+
+import com.hyunjung.core.model.AuthTokens
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.User
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginResponse(
+ val code: Int,
+ val message: String,
+ val result: LoginResultResponse
+)
+
+// TODO: Populate email, nickname, and profileImage when user profile API is added
+fun LoginResponse.toDomain(): LoginResult = LoginResult(
+ user = User(
+ userId = result.userId,
+ email = null,
+ nickname = null,
+ profileImage = null
+ ),
+ tokens = AuthTokens(
+ accessToken = result.tokens.accessToken,
+ refreshToken = result.tokens.refreshToken
+ )
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/model/LoginResultResponse.kt b/core/network/src/main/java/com/hyunjung/core/network/model/LoginResultResponse.kt
new file mode 100644
index 0000000..0d87a0e
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/model/LoginResultResponse.kt
@@ -0,0 +1,9 @@
+package com.hyunjung.core.network.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginResultResponse(
+ val userId: Long,
+ val tokens: TokenResponse
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/model/SocialLoginRequestResponse.kt b/core/network/src/main/java/com/hyunjung/core/network/model/SocialLoginRequestResponse.kt
new file mode 100644
index 0000000..d4e3d90
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/model/SocialLoginRequestResponse.kt
@@ -0,0 +1,10 @@
+package com.hyunjung.core.network.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SocialLoginRequestResponse(
+ val accessToken: String,
+ val fcmToken: String? = null,
+ val deviceType: String = "android"
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/model/TokenResponse.kt b/core/network/src/main/java/com/hyunjung/core/network/model/TokenResponse.kt
new file mode 100644
index 0000000..4b85dee
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/model/TokenResponse.kt
@@ -0,0 +1,12 @@
+package com.hyunjung.core.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TokenResponse(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("refreshToken")
+ val refreshToken: String
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/resource/AuthResource.kt b/core/network/src/main/java/com/hyunjung/core/network/resource/AuthResource.kt
new file mode 100644
index 0000000..42c8e79
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/resource/AuthResource.kt
@@ -0,0 +1,24 @@
+package com.hyunjung.core.network.resource
+
+import io.ktor.resources.Resource
+
+@Resource("/api/auth")
+class AuthResource() {
+ @Resource("kakao")
+ class Kakao(val parent: AuthResource = AuthResource()) {
+ @Resource("login")
+ class Login(val parent: Kakao = Kakao())
+ }
+
+ @Resource("naver")
+ class Naver(val parent: AuthResource = AuthResource()) {
+ @Resource("login")
+ class Login(val parent: Naver = Naver())
+ }
+
+ @Resource("google")
+ class Google(val parent: AuthResource = AuthResource()) {
+ @Resource("login")
+ class Login(val parent: Google = Google())
+ }
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/token/AuthTokenManager.kt b/core/network/src/main/java/com/hyunjung/core/network/token/AuthTokenManager.kt
new file mode 100644
index 0000000..9c65725
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/token/AuthTokenManager.kt
@@ -0,0 +1,53 @@
+package com.hyunjung.core.network.token
+
+import android.content.Context
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+class AuthTokenManager(private val context: Context) : TokenManager {
+
+ companion object {
+ private const val PREFS_NAME = "auth_tokens"
+ private const val ACCESS_TOKEN_KEY = "access_token"
+ private const val REFRESH_TOKEN_KEY = "refresh_token"
+ }
+
+ private val masterKeyAlias = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val sharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ masterKeyAlias,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ override fun saveTokens(accessToken: String, refreshToken: String) {
+ sharedPreferences.edit {
+ putString(ACCESS_TOKEN_KEY, accessToken)
+ .putString(REFRESH_TOKEN_KEY, refreshToken)
+ }
+ }
+
+ override fun getAccessToken(): String? {
+ return sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
+ }
+
+ override fun getRefreshToken(): String? {
+ return sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
+ }
+
+ override fun clearTokens() {
+ sharedPreferences.edit {
+ remove(ACCESS_TOKEN_KEY)
+ .remove(REFRESH_TOKEN_KEY)
+ }
+ }
+
+ fun hasValidTokens(): Boolean {
+ return !getAccessToken().isNullOrEmpty() && !getRefreshToken().isNullOrEmpty()
+ }
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/token/TokenManager.kt b/core/network/src/main/java/com/hyunjung/core/network/token/TokenManager.kt
new file mode 100644
index 0000000..8534878
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/token/TokenManager.kt
@@ -0,0 +1,8 @@
+package com.hyunjung.core.network.token
+
+interface TokenManager {
+ fun getAccessToken(): String?
+ fun getRefreshToken(): String?
+ fun saveTokens(accessToken: String, refreshToken: String)
+ fun clearTokens()
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/hyunjung/core/network/util/HttpClientExt.kt b/core/network/src/main/java/com/hyunjung/core/network/util/HttpClientExt.kt
new file mode 100644
index 0000000..1070ee5
--- /dev/null
+++ b/core/network/src/main/java/com/hyunjung/core/network/util/HttpClientExt.kt
@@ -0,0 +1,112 @@
+package com.hyunjung.core.network.util
+
+import com.hyunjung.cherrydan.core.network.BuildConfig
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.resources.post
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.request.url
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.util.network.UnresolvedAddressException
+import kotlinx.serialization.SerializationException
+import kotlin.coroutines.cancellation.CancellationException
+
+suspend inline fun HttpClient.get(
+ route: String,
+ queryParameters: Map = mapOf()
+): Result {
+ return safeCall {
+ get {
+ url(constructRoute(route))
+ queryParameters.forEach { (key, value) ->
+ parameter(key, value)
+ }
+ }
+ }
+}
+
+suspend inline fun HttpClient.delete(
+ route: String,
+ queryParameters: Map = mapOf()
+): Result {
+ return safeCall {
+ delete {
+ url(constructRoute(route))
+ queryParameters.forEach { (key, value) ->
+ parameter(key, value)
+ }
+ }
+ }
+}
+
+suspend inline fun HttpClient.post(
+ route: String,
+ body: Any
+): Result {
+ return safeCall {
+ post {
+ url(constructRoute(route))
+ contentType(ContentType.Application.Json)
+ setBody(body)
+ }
+ }
+}
+
+suspend inline fun HttpClient.post(
+ resource: Resource,
+ body: Any
+): Result {
+ return safeCall {
+ post(resource) {
+ contentType(ContentType.Application.Json)
+ setBody(body)
+ }.body()
+ }
+}
+
+suspend inline fun safeCall(execute: suspend () -> HttpResponse): Result {
+ val response = try {
+ execute()
+ } catch (e: UnresolvedAddressException) {
+ e.printStackTrace()
+ return Result.Error(DataError.Network.NO_INTERNET)
+ } catch (e: SerializationException) {
+ e.printStackTrace()
+ return Result.Error(DataError.Network.SERIALIZATION)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ e.printStackTrace()
+ return Result.Error(DataError.Network.UNKNOWN)
+ }
+
+ return responseToResult(response)
+}
+
+suspend inline fun responseToResult(response: HttpResponse): Result {
+ return when (response.status.value) {
+ in 200..299 -> Result.Success(response.body())
+ 401 -> Result.Error(DataError.Network.UNAUTHORIZED)
+ 408 -> Result.Error(DataError.Network.REQUEST_TIMEOUT)
+ 409 -> Result.Error(DataError.Network.CONFLICT)
+ 413 -> Result.Error(DataError.Network.PAYLOAD_TOO_LARGE)
+ 429 -> Result.Error(DataError.Network.TOO_MANY_REQUESTS)
+ in 500..599 -> Result.Error(DataError.Network.SERVER_ERROR)
+ else -> Result.Error(DataError.Network.UNKNOWN)
+ }
+}
+
+fun constructRoute(route: String): String {
+ return when {
+ route.contains(BuildConfig.BASE_URL) -> route
+ route.startsWith("/") -> BuildConfig.BASE_URL + route
+ else -> BuildConfig.BASE_URL + "/$route"
+ }
+}
\ No newline at end of file
diff --git a/core/presentation/ui/src/main/java/com/hyunjung/core/presentation/ui/DataErrorToText.kt b/core/presentation/ui/src/main/java/com/hyunjung/core/presentation/ui/DataErrorToText.kt
index bffe647..24a4cf7 100644
--- a/core/presentation/ui/src/main/java/com/hyunjung/core/presentation/ui/DataErrorToText.kt
+++ b/core/presentation/ui/src/main/java/com/hyunjung/core/presentation/ui/DataErrorToText.kt
@@ -1,6 +1,6 @@
package com.hyunjung.core.presentation.ui
-import com.hyunjung.core.domain.util.DataError
+import com.hyunjung.core.common.util.DataError
fun DataError.asUiText(): UiText {
return when (this) {
diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts
index 755c5ab..482b643 100644
--- a/feature/auth/build.gradle.kts
+++ b/feature/auth/build.gradle.kts
@@ -7,5 +7,7 @@ android {
}
dependencies {
+ implementation(libs.timber)
+
implementation(projects.core.domain)
}
\ No newline at end of file
diff --git a/feature/auth/src/main/java/com/hyunjung/feature/auth/di/AuthViewModelModule.kt b/feature/auth/src/main/java/com/hyunjung/feature/auth/di/AuthViewModelModule.kt
new file mode 100644
index 0000000..d50b77c
--- /dev/null
+++ b/feature/auth/src/main/java/com/hyunjung/feature/auth/di/AuthViewModelModule.kt
@@ -0,0 +1,9 @@
+package com.hyunjung.feature.auth.di
+
+import com.hyunjung.feature.auth.login.LogInViewModel
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+
+val authViewModelModule = module {
+ viewModel { LogInViewModel(get()) }
+}
\ No newline at end of file
diff --git a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInAction.kt b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInAction.kt
deleted file mode 100644
index e9f4513..0000000
--- a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInAction.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.hyunjung.feature.auth.login
-
-interface LogInAction {
- data object OnKakaoLogInClick : LogInAction
- data object OnNaverLogInClick : LogInAction
- data object OnGoogleLogInClick : LogInAction
-}
\ No newline at end of file
diff --git a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInScreen.kt b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInScreen.kt
index 98e4ee1..d25242f 100644
--- a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInScreen.kt
+++ b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInScreen.kt
@@ -6,21 +6,31 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.SnackbarHostState
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.hyunjung.core.model.SocialType
import com.hyunjung.core.presentation.designsystem.CherrydanColors
import com.hyunjung.core.presentation.designsystem.CherrydanTheme
import com.hyunjung.core.presentation.designsystem.CherrydanTypography
@@ -28,40 +38,55 @@ import com.hyunjung.core.presentation.designsystem.component.CherrydanLogInButto
import com.hyunjung.core.presentation.designsystem.component.CherrydanLogInButtonType
import com.hyunjung.core.presentation.designsystem.component.CherrydanLogInOutlinedButton
import com.hyunjung.core.presentation.ui.R
+import org.koin.androidx.compose.koinViewModel
+// todo : LoginUiState에 따른 화면 만들기
@Composable
fun LogInScreenRoot(
onKakaoLogInClick: () -> Unit,
onNaverLogInClick: () -> Unit,
onGoogleLogInClick: () -> Unit,
+ onLoginSuccess: () -> Unit,
+ viewModel: LogInViewModel = koinViewModel()
) {
- LogInScreen(
- onAction = { action ->
- when (action) {
- LogInAction.OnKakaoLogInClick -> {
- onKakaoLogInClick()
- }
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
- LogInAction.OnNaverLogInClick -> {
- onNaverLogInClick()
- }
+ // 로그인 성공 시 네비게이션
+ LaunchedEffect(uiState) {
+ if (uiState is LoginUiState.Success) {
+ onLoginSuccess()
+ }
+ }
- LogInAction.OnGoogleLogInClick -> {
- onGoogleLogInClick()
- }
- }
+ // 에러 메시지 표시
+ LaunchedEffect(uiState) {
+ val error = (uiState as? LoginUiState.Error)?.error
+ error?.toString()?.let {
+ snackbarHostState.showSnackbar(it)
+ }
+ }
+
+ val localContext = LocalContext.current
+
+ LogInScreen(
+ state = uiState,
+ onLogIn = { socialType ->
+ viewModel.login(localContext, socialType)
}
)
}
@Composable
fun LogInScreen(
- onAction: (LogInAction) -> Unit
+ state: LoginUiState,
+ onLogIn: (SocialType) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
- .background(CherrydanColors.PointBeige),
+ .background(CherrydanColors.PointBeige)
+ .windowInsetsPadding(WindowInsets.systemBars),
contentAlignment = Alignment.Center
) {
Column(
@@ -88,28 +113,22 @@ fun LogInScreen(
Spacer(modifier = Modifier.height(16.dp))
CherrydanLogInButton(
text = stringResource(id = R.string.login_kakao),
- isLoading = false,
+ isLoading = state == LoginUiState.Loading,
logInButtonType = CherrydanLogInButtonType.Kakao,
- onClick = {
- onAction(LogInAction.OnKakaoLogInClick)
- },
+ onClick = { onLogIn(SocialType.KAKAO) },
)
Spacer(modifier = Modifier.height(8.dp))
CherrydanLogInButton(
text = stringResource(id = R.string.login_naver),
- isLoading = false,
+ isLoading = state == LoginUiState.Loading,
logInButtonType = CherrydanLogInButtonType.Naver,
- onClick = {
- onAction(LogInAction.OnNaverLogInClick)
- },
+ onClick = { onLogIn(SocialType.NAVER) },
)
Spacer(modifier = Modifier.height(8.dp))
CherrydanLogInOutlinedButton(
text = stringResource(id = R.string.login_google),
- isLoading = false,
- onClick = {
- onAction(LogInAction.OnGoogleLogInClick)
- },
+ isLoading = state == LoginUiState.Loading,
+ onClick = { onLogIn(SocialType.GOOGLE) },
)
Spacer(modifier = Modifier.height(44.dp))
}
@@ -141,7 +160,8 @@ private fun CherrydanLogoHorizontal() {
private fun LogInScreenPreview() {
CherrydanTheme {
LogInScreen(
- onAction = {}
+ state = LoginUiState.Loading,
+ onLogIn = {}
)
}
}
\ No newline at end of file
diff --git a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInState.kt b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInState.kt
new file mode 100644
index 0000000..7379928
--- /dev/null
+++ b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInState.kt
@@ -0,0 +1,10 @@
+package com.hyunjung.feature.auth.login
+
+import com.hyunjung.core.model.User
+
+data class LogInState(
+ val isLoading: Boolean = false,
+ val isLoggedIn: Boolean = false,
+ val user: User? = null,
+ val errorMessage: String? = null
+)
\ No newline at end of file
diff --git a/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInViewModel.kt b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInViewModel.kt
new file mode 100644
index 0000000..d489ce4
--- /dev/null
+++ b/feature/auth/src/main/java/com/hyunjung/feature/auth/login/LogInViewModel.kt
@@ -0,0 +1,45 @@
+package com.hyunjung.feature.auth.login
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.hyunjung.core.common.util.DataError
+import com.hyunjung.core.common.util.Result
+import com.hyunjung.core.domain.repository.AuthRepository
+import com.hyunjung.core.model.LoginResult
+import com.hyunjung.core.model.SocialType
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class LogInViewModel(
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(LoginUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun login(context: Context, socialType: SocialType) {
+ viewModelScope.launch {
+ try {
+ _uiState.update { LoginUiState.Loading }
+ when (val result = authRepository.login(context, socialType).first()) {
+ is Result.Success -> _uiState.update { LoginUiState.Success(result.data) }
+ is Result.Error -> _uiState.update { LoginUiState.Error(result.error) }
+ }
+ } catch (e: Exception) {
+ _uiState.update { LoginUiState.Error(DataError.Network.UNKNOWN) }
+ }
+ }
+ }
+}
+
+sealed interface LoginUiState {
+ object Idle : LoginUiState
+ object Loading : LoginUiState
+ data class Success(val loginResult: LoginResult) : LoginUiState
+ data class Error(val error: DataError) : LoginUiState
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 315a73e..4c43877 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -36,6 +36,7 @@ coroutines = "1.8.0"
gmsLocation = "21.2.0"
mapsUtils = "5.0.0"
bson = "4.11.1"
+kakao = "2.21.4"
work = "2.9.0"
kotlinx-serialization = "1.6.3"
secretsPlugin = "2.0.1"
@@ -49,6 +50,7 @@ projectMinSdkVersion = "24"
projectTargetSdkVersion = "35"
projectCompileSdkVersion = "35"
projectVersionCode = "1"
+firebaseFirestoreKtx = "25.1.4"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -89,6 +91,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" }
+ktor-client-resources = { module = "io.ktor:ktor-client-resources", version.ref = "ktor"}
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktorServerCallLogging" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
@@ -118,6 +121,10 @@ android-tools-common = { group = "com.android.tools", name = "common", version.r
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" }
+kakao-auth = { module = "com.kakao.sdk:v2-auth", version.ref = "kakao" }
+kakao-common = { module = "com.kakao.sdk:v2-common", version.ref = "kakao" }
+kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" }
+firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebaseFirestoreKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -162,5 +169,6 @@ ktor = [
"ktor-client-content-negotiation",
"ktor-client-core",
"ktor-client-logging",
+ "ktor-client-resources",
"ktor-serialization-kotlinx-json"
]
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c38bafc..18ca83f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -17,6 +17,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") }
}
}
@@ -24,13 +25,14 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "Cherrydan"
include(":app")
include(":core")
-include(":core:presentation:designsystem")
-include(":core:presentation:ui")
+include(":core:common")
include(":core:domain")
include(":core:data")
include(":core:database")
include(":core:network")
include(":core:model")
+include(":core:presentation:designsystem")
+include(":core:presentation:ui")
include(":feature:auth")
include(":feature:home")
include(":feature:notification")