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