diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index fce09ba6..941a3285 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -48,9 +48,11 @@ jobs: env: DEV_BASE_URL: ${{ secrets.DEV_BASE_URL }} PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} run: | echo "dev_base_url=$DEV_BASE_URL" >> local.properties echo "prod_base_url=$PROD_BASE_URL" >> local.properties + echo "google_client_id=$GOOGLE_CLIENT_ID" >> local.properties - name: Create google-services.json env: diff --git a/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt b/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt index 74e94a90..93ee0e44 100644 --- a/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt +++ b/core/datastore/src/main/java/com/twix/datastore/AuthConfigure.kt @@ -11,14 +11,8 @@ import java.io.OutputStream @Serializable internal data class AuthConfigure( - val accessToken: String = - "eyJhbGciOiJIUzM4NCJ9." + - "eyJzdWIiOiIxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc3MDI0NzM0NCwiZXhwIjoxNzcwODUyMTQ0fQ." + - "67rDscm8BeayYFA1gfcEMliEdEh8-HTUyE5TwmAT8Ef8ZvtaWczxpMNZqI5htiek", - val refreshToken: String = - "eyJhbGciOiJIUzM4NCJ9." + - "eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3NzAyNDczNDQsImV4cCI6MTc3MDg1MjE0NH0." + - "zgUYdR6onyeY5EaH2_pWLs1rjNLf8m8ZeXsY7Cbk99a_2tzR0rDBZO_hdGTnorRL", + val accessToken: String = "", + val refreshToken: String = "", ) internal object AuthConfigureSerializer : Serializer { diff --git a/core/design-system/src/main/res/drawable/ic_google.xml b/core/design-system/src/main/res/drawable/ic_google.xml new file mode 100644 index 00000000..8443d979 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_google.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt b/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt index 65027324..6a20fb4a 100644 --- a/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt +++ b/core/navigation/src/main/java/com/twix/navigation/AppNavHost.kt @@ -23,7 +23,7 @@ fun AppNavHost() { } val start = contributors - .firstOrNull { it.graphRoute == NavRoutes.MainGraph } + .firstOrNull { it.graphRoute == NavRoutes.LoginGraph } ?.graphRoute ?: error("해당 Graph를 찾을 수 없습니다.") val duration = 300 diff --git a/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt b/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt index 588db4b0..4d7a5c33 100644 --- a/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt +++ b/core/network/src/main/java/com/twix/network/model/request/LoginRequest.kt @@ -4,5 +4,5 @@ import kotlinx.serialization.Serializable @Serializable data class LoginRequest( - val code: String, + val idToken: String, ) diff --git a/core/network/src/main/java/com/twix/network/service/AuthService.kt b/core/network/src/main/java/com/twix/network/service/AuthService.kt index b41d4bee..6f87570b 100644 --- a/core/network/src/main/java/com/twix/network/service/AuthService.kt +++ b/core/network/src/main/java/com/twix/network/service/AuthService.kt @@ -8,7 +8,7 @@ import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.POST interface AuthService { - @POST("auth/google") + @POST("auth/google/token") suspend fun googleLogin( @Body request: LoginRequest, ): LoginResponse diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 6e4a6774..a2d22455 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { implementation(projects.core.datastore) + implementation(projects.core.token) } diff --git a/data/src/main/java/com/twix/data/di/RepositoryModule.kt b/data/src/main/java/com/twix/data/di/RepositoryModule.kt index bc50e95d..a3d7bd3c 100644 --- a/data/src/main/java/com/twix/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/twix/data/di/RepositoryModule.kt @@ -1,6 +1,8 @@ package com.twix.data.di +import com.twix.data.repository.DefaultAuthRepository import com.twix.data.repository.DefaultOnboardingRepository +import com.twix.domain.repository.AuthRepository import com.twix.domain.repository.OnBoardingRepository import org.koin.dsl.module @@ -9,4 +11,7 @@ internal val repositoryModule = single { DefaultOnboardingRepository(get()) } + single { + DefaultAuthRepository(get(), get()) + } } diff --git a/data/src/main/java/com/twix/data/repository/DefaultAuthRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultAuthRepository.kt new file mode 100644 index 00000000..d41c049f --- /dev/null +++ b/data/src/main/java/com/twix/data/repository/DefaultAuthRepository.kt @@ -0,0 +1,25 @@ +package com.twix.data.repository + +import com.twix.domain.login.LoginType +import com.twix.domain.repository.AuthRepository +import com.twix.network.model.request.LoginRequest +import com.twix.network.service.AuthService +import com.twix.token.TokenProvider + +class DefaultAuthRepository( + private val service: AuthService, + private val tokenProvider: TokenProvider, +) : AuthRepository { + override suspend fun login( + idToken: String, + type: LoginType, + ) { + val response = + when (type) { + LoginType.GOOGLE -> service.googleLogin(LoginRequest(idToken)) + LoginType.KAKAO -> return + } + + tokenProvider.saveToken(response.accessToken, response.refreshToken) + } +} diff --git a/domain/src/main/java/com/twix/domain/login/LoginProvider.kt b/domain/src/main/java/com/twix/domain/login/LoginProvider.kt new file mode 100644 index 00000000..4d9d74f4 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/login/LoginProvider.kt @@ -0,0 +1,7 @@ +package com.twix.domain.login + +interface LoginProvider { + suspend fun login(): LoginResult + + suspend fun logout(): Result +} diff --git a/domain/src/main/java/com/twix/domain/login/LoginResult.kt b/domain/src/main/java/com/twix/domain/login/LoginResult.kt new file mode 100644 index 00000000..4505cf8e --- /dev/null +++ b/domain/src/main/java/com/twix/domain/login/LoginResult.kt @@ -0,0 +1,14 @@ +package com.twix.domain.login + +sealed interface LoginResult { + data class Success( + val idToken: String, + val type: LoginType, + ) : LoginResult + + data object Cancel : LoginResult + + data class Failure( + val throwable: Throwable?, + ) : LoginResult +} diff --git a/domain/src/main/java/com/twix/domain/login/LoginType.kt b/domain/src/main/java/com/twix/domain/login/LoginType.kt new file mode 100644 index 00000000..4582a6c3 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/login/LoginType.kt @@ -0,0 +1,6 @@ +package com.twix.domain.login + +enum class LoginType { + GOOGLE, + KAKAO, +} diff --git a/domain/src/main/java/com/twix/domain/repository/AuthRepository.kt b/domain/src/main/java/com/twix/domain/repository/AuthRepository.kt new file mode 100644 index 00000000..376d521b --- /dev/null +++ b/domain/src/main/java/com/twix/domain/repository/AuthRepository.kt @@ -0,0 +1,10 @@ +package com.twix.domain.repository + +import com.twix.domain.login.LoginType + +interface AuthRepository { + suspend fun login( + idToken: String, + type: LoginType, + ) +} diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts index 87722a6e..37e1a158 100644 --- a/feature/login/build.gradle.kts +++ b/feature/login/build.gradle.kts @@ -1,7 +1,35 @@ +import java.util.Properties +import kotlin.apply + plugins { alias(libs.plugins.twix.feature) } +val localProperties = + Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } + } + android { namespace = "com.twix.login" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField( + "String", + "GOOGLE_CLIENT_ID", + "\"${localProperties.getProperty("google_client_id")}\"", + ) + } +} +dependencies { + implementation(libs.googleid) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services.auth) } diff --git a/feature/login/src/main/java/com/twix/login/LoginProviderFactory.kt b/feature/login/src/main/java/com/twix/login/LoginProviderFactory.kt new file mode 100644 index 00000000..aa2c3d05 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/LoginProviderFactory.kt @@ -0,0 +1,16 @@ +package com.twix.login + +import com.twix.domain.login.LoginProvider +import com.twix.domain.login.LoginType + +class LoginProviderFactory( + private val providers: Map, +) { + operator fun get(type: LoginType): LoginProvider = + providers[type] + ?: throw IllegalArgumentException(UNSUPPORTED_LOGIN_TYPE.format(type)) + + companion object { + private const val UNSUPPORTED_LOGIN_TYPE = "Unsupported login type: %s" + } +} diff --git a/feature/login/src/main/java/com/twix/login/LoginScreen.kt b/feature/login/src/main/java/com/twix/login/LoginScreen.kt index 96271369..73ca722d 100644 --- a/feature/login/src/main/java/com/twix/login/LoginScreen.kt +++ b/feature/login/src/main/java/com/twix/login/LoginScreen.kt @@ -1,23 +1,84 @@ package com.twix.login +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.components.toast.ToastManager +import com.twix.designsystem.components.toast.model.ToastData +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.login.LoginType +import com.twix.login.LoginProviderFactory +import com.twix.login.component.LoginButton +import com.twix.login.model.LoginIntent +import com.twix.login.model.LoginSideEffect +import com.twix.ui.base.ObserveAsEvents +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable -fun LoginScreen() { - LoginContent() +fun LoginRoute( + navigateToHome: () -> Unit, + navigateToOnBoarding: () -> Unit, + toastManager: ToastManager = koinInject(), + loginProvider: LoginProviderFactory = koinInject(), + viewModel: LoginViewModel = koinViewModel(), +) { + val coroutineScope = rememberCoroutineScope() + + val loginFailMessage = stringResource(R.string.login_fail_message) + ObserveAsEvents(viewModel.sideEffect) { sideEffect -> + when (sideEffect) { + LoginSideEffect.NavigateToHome -> navigateToHome() + LoginSideEffect.NavigateToOnBoarding -> navigateToOnBoarding() + LoginSideEffect.ShowLoginFailToast -> { + toastManager.tryShow(ToastData(loginFailMessage, ToastType.ERROR)) + } + } + } + + LoginScreen { type -> + coroutineScope.launch { + viewModel.dispatch(LoginIntent.Login(loginProvider[type].login())) + } + } } @Composable -private fun LoginContent() { +private fun LoginScreen(onClickLogin: (LoginType) -> Unit) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Text("임시 화면입니다.") + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // LoginType.entries.forEach { type -> + LoginButton(type = LoginType.GOOGLE, onClickLogin = onClickLogin) + // } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreview() { + TwixTheme { + LoginScreen(onClickLogin = {}) } } diff --git a/feature/login/src/main/java/com/twix/login/LoginViewModel.kt b/feature/login/src/main/java/com/twix/login/LoginViewModel.kt new file mode 100644 index 00000000..7cce7dbf --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/LoginViewModel.kt @@ -0,0 +1,35 @@ +package com.twix.login + +import androidx.lifecycle.viewModelScope +import com.twix.domain.login.LoginResult +import com.twix.domain.repository.AuthRepository +import com.twix.login.model.LoginIntent +import com.twix.login.model.LoginSideEffect +import com.twix.login.model.LoginUiState +import com.twix.ui.base.BaseViewModel +import kotlinx.coroutines.launch + +class LoginViewModel( + private val authRepository: AuthRepository, +) : BaseViewModel(LoginUiState()) { + override suspend fun handleIntent(intent: LoginIntent) { + when (intent) { + is LoginIntent.Login -> login(intent.result) + } + } + + private fun login(result: LoginResult) { + viewModelScope.launch { + when (result) { + is LoginResult.Success -> { + authRepository.login(result.idToken, result.type) + emitSideEffect(LoginSideEffect.NavigateToHome) + } + is LoginResult.Failure -> { + emitSideEffect(LoginSideEffect.ShowLoginFailToast) + } + LoginResult.Cancel -> Unit + } + } + } +} diff --git a/feature/login/src/main/java/com/twix/login/component/LoginButton.kt b/feature/login/src/main/java/com/twix/login/component/LoginButton.kt new file mode 100644 index 00000000..7cc51480 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/component/LoginButton.kt @@ -0,0 +1,97 @@ +package com.twix.login.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.login.LoginType +import com.twix.domain.model.enums.AppTextStyle +import com.twix.login.R +import com.twix.login.model.LoginTypeUiModel +import com.twix.ui.extension.noRippleClickable +import com.twix.designsystem.R as DesR + +@Composable +fun LoginButton( + type: LoginType, + onClickLogin: (LoginType) -> Unit, +) { + val uiModel = type.uiModel() + Row( + modifier = + Modifier + .fillMaxWidth() + .height(54.dp) + .clip(RoundedCornerShape(12.dp)) + .background(uiModel.background) + .border(1.dp, uiModel.border, RoundedCornerShape(12.dp)) + .noRippleClickable { onClickLogin(type) }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = uiModel.logo, + contentDescription = null, + ) + + Spacer(Modifier.width(12.dp)) + + AppText( + text = uiModel.title, + style = AppTextStyle.T3, + color = uiModel.textColor, + ) + } +} + +@Composable +private fun LoginType.uiModel(): LoginTypeUiModel = + when (this) { + LoginType.GOOGLE -> + LoginTypeUiModel( + logo = ImageVector.vectorResource(DesR.drawable.ic_google), + title = stringResource(R.string.google_login_button_title), + background = CommonColor.White, + border = GrayColor.C200, + textColor = GrayColor.C500, + ) + // TODO : KAKAO용으로 수정 + LoginType.KAKAO -> + LoginTypeUiModel( + logo = ImageVector.vectorResource(DesR.drawable.ic_google), + title = stringResource(R.string.google_login_button_title), + background = CommonColor.White, + border = GrayColor.C200, + textColor = GrayColor.C500, + ) + } + +@Preview(showBackground = true) +@Composable +fun LoginButtonPreview() { + TwixTheme { + LoginButton( + type = LoginType.GOOGLE, + onClickLogin = {}, + ) + } +} diff --git a/feature/login/src/main/java/com/twix/login/di/LoginModule.kt b/feature/login/src/main/java/com/twix/login/di/LoginModule.kt index 3a74a246..ebe53207 100644 --- a/feature/login/src/main/java/com/twix/login/di/LoginModule.kt +++ b/feature/login/src/main/java/com/twix/login/di/LoginModule.kt @@ -1,12 +1,33 @@ package com.twix.login.di +import com.twix.domain.login.LoginProvider +import com.twix.domain.login.LoginType +import com.twix.login.LoginProviderFactory +import com.twix.login.LoginViewModel +import com.twix.login.google.GoogleLoginProvider import com.twix.login.navigation.LoginNavGraph import com.twix.navigation.NavRoutes import com.twix.navigation.base.NavGraphContributor +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.viewModelOf import org.koin.core.qualifier.named import org.koin.dsl.module val loginModule = module { single(named(NavRoutes.LoginGraph.route)) { LoginNavGraph } + + single(named(LoginType.GOOGLE.name)) { + GoogleLoginProvider(androidContext()) + } + + single { + LoginProviderFactory( + mapOf( + LoginType.GOOGLE to get(named(LoginType.GOOGLE.name)), + ), + ) + } + + viewModelOf(::LoginViewModel) } diff --git a/feature/login/src/main/java/com/twix/login/google/GoogleCredentialResult.kt b/feature/login/src/main/java/com/twix/login/google/GoogleCredentialResult.kt new file mode 100644 index 00000000..4eb0e893 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/google/GoogleCredentialResult.kt @@ -0,0 +1,13 @@ +package com.twix.login.google + +sealed interface GoogleCredentialResult { + data class Success( + val idToken: String, + ) : GoogleCredentialResult + + data class Failure( + val exception: Throwable?, + ) : GoogleCredentialResult + + data object Cancel : GoogleCredentialResult +} diff --git a/feature/login/src/main/java/com/twix/login/google/GoogleLoginProvider.kt b/feature/login/src/main/java/com/twix/login/google/GoogleLoginProvider.kt new file mode 100644 index 00000000..8a808c25 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/google/GoogleLoginProvider.kt @@ -0,0 +1,166 @@ +package com.twix.login.google + +import android.content.Context +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager +import androidx.credentials.CredentialOption +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.twix.domain.login.LoginProvider +import com.twix.domain.login.LoginResult +import com.twix.domain.login.LoginType +import com.twix.login.BuildConfig + +/** + * Google 로그인 Provider 구현체. + * + * [LoginProvider] 인터페이스를 구현하여 + * UI가 Google SDK에 직접 의존하지 않도록 캡슐화 + * + * ### 로그인 흐름 + * 1. silent sign-in 시도 (기존 로그인 계정 자동 인증) + * 2. 실패 시 명시적 sign-in (계정 선택 UI 표시) + * 3. ID Token 추출 후 서버 인증에 전달 + * + */ +class GoogleLoginProvider( + private val context: Context, +) : LoginProvider { + /** Android Credential API 진입점 */ + private val credentialManager = CredentialManager.create(context) + + /** Google Cloud Console에서 발급된 Web Client ID */ + private val serverClientId = BuildConfig.GOOGLE_CLIENT_ID + + /** + * 기존 로그인된 계정을 자동 인증하기 위한 옵션 (silent login) + */ + private val googleIdOption = + GetGoogleIdOption + .Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(serverClientId) + .build() + + /** + * 사용자에게 계정 선택 UI를 보여주는 명시적 로그인 옵션 (explicit login) + */ + private val signInOption = + GetSignInWithGoogleOption + .Builder(serverClientId) + .build() + + /** silent 로그인 요청 */ + private val silentRequest = buildRequest(googleIdOption) + + /** 명시적 로그인 요청 */ + private val explicitRequest = buildRequest(signInOption) + + override suspend fun login(): LoginResult = + when (val result = getGoogleCredentialResult()) { + is GoogleCredentialResult.Success -> + LoginResult.Success(result.idToken, LoginType.GOOGLE) + + GoogleCredentialResult.Cancel -> + LoginResult.Cancel + + is GoogleCredentialResult.Failure -> + LoginResult.Failure(result.exception) + } + + /** + * Google Credential 상태를 초기화하여 로그아웃 처리 + */ + override suspend fun logout(): Result = + runCatching { + credentialManager.clearCredentialState(ClearCredentialStateRequest()) + } + + /** + * Google 인증 전체 프로세스를 수행 + * + * 1. silent 요청 시도 + * 2. 실패 시 명 요청 수행 + * + * @return Google 전용 결과 타입 + */ + private suspend fun getGoogleCredentialResult(): GoogleCredentialResult { + val silent = request(silentRequest) + + return if (silent is GoogleCredentialResult.Failure) { + request(explicitRequest) + } else { + silent + } + } + + /** + * Credential 요청을 실제로 수행하고 결과 파싱 + * + * @param request CredentialRequest 객체 + * @return GoogleCredentialResult + */ + private suspend fun request(request: GetCredentialRequest): GoogleCredentialResult = + runCatching { + credentialManager.getCredential(context, request) + }.fold( + onSuccess = { handleResponse(it) }, + onFailure = { handleException(it) }, + ) + + /** + * Credential 응답에서 Google ID Token을 추출 + * + * - CustomCredential 타입 검증 + * - GoogleIdTokenCredential 파싱 + * + * @throws NoCredentialException 유효한 Credential이 아닌 경우 + */ + private fun handleResponse(response: GetCredentialResponse): GoogleCredentialResult { + val credential = response.credential + + if (credential !is CustomCredential || + credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + return GoogleCredentialResult.Failure(NoCredentialException()) + } + + return try { + val token = + GoogleIdTokenCredential.createFrom(credential.data).idToken + GoogleCredentialResult.Success(token) + } catch (e: Throwable) { + GoogleCredentialResult.Failure(e) + } + } + + /** + * Credential 요청 중 발생한 예외를 Google 전용 Result로 변환 + * + * - Cancellation → Cancel + * - CredentialException → Suspending (다음 단계 진행 가능) + * - 기타 → Failure + */ + private fun handleException(e: Throwable): GoogleCredentialResult = + when (e) { + is GetCredentialCancellationException -> GoogleCredentialResult.Cancel + is GetCredentialException -> GoogleCredentialResult.Failure(e) + else -> GoogleCredentialResult.Failure(e) + } + + /** + * CredentialOption으로부터 GetCredentialRequest 생성 + */ + private fun buildRequest(option: CredentialOption) = + GetCredentialRequest + .Builder() + .addCredentialOption(option) + .build() +} diff --git a/feature/login/src/main/java/com/twix/login/model/LoginIntent.kt b/feature/login/src/main/java/com/twix/login/model/LoginIntent.kt new file mode 100644 index 00000000..a3b39a21 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/model/LoginIntent.kt @@ -0,0 +1,10 @@ +package com.twix.login.model + +import com.twix.domain.login.LoginResult +import com.twix.ui.base.Intent + +sealed interface LoginIntent : Intent { + data class Login( + val result: LoginResult, + ) : LoginIntent +} diff --git a/feature/login/src/main/java/com/twix/login/model/LoginSideEffect.kt b/feature/login/src/main/java/com/twix/login/model/LoginSideEffect.kt new file mode 100644 index 00000000..8e1ea465 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/model/LoginSideEffect.kt @@ -0,0 +1,11 @@ +package com.twix.login.model + +import com.twix.ui.base.SideEffect + +sealed interface LoginSideEffect : SideEffect { + data object NavigateToHome : LoginSideEffect + + data object NavigateToOnBoarding : LoginSideEffect + + data object ShowLoginFailToast : LoginSideEffect +} diff --git a/feature/login/src/main/java/com/twix/login/model/LoginTypeUiModel.kt b/feature/login/src/main/java/com/twix/login/model/LoginTypeUiModel.kt new file mode 100644 index 00000000..81b37576 --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/model/LoginTypeUiModel.kt @@ -0,0 +1,12 @@ +package com.twix.login.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +data class LoginTypeUiModel( + val logo: ImageVector, + val title: String, + val background: Color, + val border: Color, + val textColor: Color, +) diff --git a/feature/login/src/main/java/com/twix/login/model/LoginUiState.kt b/feature/login/src/main/java/com/twix/login/model/LoginUiState.kt new file mode 100644 index 00000000..cfd46b8e --- /dev/null +++ b/feature/login/src/main/java/com/twix/login/model/LoginUiState.kt @@ -0,0 +1,7 @@ +package com.twix.login.model + +import com.twix.ui.base.State + +data class LoginUiState( + val isLoggedIn: Boolean = false, +) : State diff --git a/feature/login/src/main/java/com/twix/login/navigation/LoginNavGraph.kt b/feature/login/src/main/java/com/twix/login/navigation/LoginNavGraph.kt index 2d609eba..72d8c1de 100644 --- a/feature/login/src/main/java/com/twix/login/navigation/LoginNavGraph.kt +++ b/feature/login/src/main/java/com/twix/login/navigation/LoginNavGraph.kt @@ -4,7 +4,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.navigation -import com.twix.login.LoginScreen +import com.twix.login.LoginRoute import com.twix.navigation.NavRoutes import com.twix.navigation.base.NavGraphContributor @@ -20,7 +20,16 @@ object LoginNavGraph : NavGraphContributor { startDestination = startDestination, ) { composable(NavRoutes.LoginRoute.route) { - LoginScreen() + LoginRoute( + navigateToHome = { + navController.navigate(NavRoutes.MainGraph.route) { + popUpTo(NavRoutes.LoginGraph.route) { + inclusive = true + } + } + }, + navigateToOnBoarding = { }, + ) } } } diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml new file mode 100644 index 00000000..d27f06f5 --- /dev/null +++ b/feature/login/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Google로 시작하기 + 로그인에 실패했습니다. 다시 시도해주세요 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d257bbde..d873bfde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ kotlinx-serialization-json = "1.9.0" # AndroidX androidx-core-ktx = "1.17.0" androidx-lifecycle-runtime-ktx = "2.10.0" +androidx-credentials = "1.5.0" androidx-datastore = "1.2.0" # Google @@ -22,6 +23,7 @@ material = "1.13.0" google-services = "4.4.4" google-firebase-bom = "34.7.0" google-firebase-crashlytics = "3.0.6" +googleid = "1.2.0" # Compose activity-compose = "1.12.0" @@ -65,14 +67,14 @@ jetbrains-kotlin-jvm = "2.1.0" # Logging kermit = "2.0.8" junit = "4.13.2" -espressoCore = "3.7.0" -appcompat = "1.7.1" [libraries] # AndroidX androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } # CameraX androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } @@ -92,6 +94,7 @@ google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } google-firebase-analytics = { module = "com.google.firebase:firebase-analytics" } google-firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } # Kotlin kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" }