diff --git a/.editorconfig b/.editorconfig index 5e0bd1f9..b65b2d92 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,4 @@ indent_style = space trim_trailing_whitespace = true insert_final_newline = true tab_width = 4 -ij_kotlin_allow_trailing_comma = true -ktlint_standard_function-name = false +ij_kotlin_allow_trailing_comma = true \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36aeee14..b881960d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.com.android.application) alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.googleServices) + id("kotlin-kapt") } android { @@ -67,5 +70,15 @@ dependencies { debugImplementation(libs.androidx.ui.test.manifest) implementation(project(":core:ui")) + implementation(project(":data")) + implementation(project(":domain")) implementation(project(":feature:login")) + + // hilt + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + // firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth.ktx) } diff --git a/app/google-services.json b/app/google-services.json index e69de29b..3566f694 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -0,0 +1,62 @@ +{ + "project_info": { + "project_number": "217769178527", + "project_id": "pokit-f5c83", + "storage_bucket": "pokit-f5c83.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:217769178527:android:5d7efa4ceafe61c37af9aa", + "android_client_info": { + "package_name": "pokitmons.pokit" + } + }, + "oauth_client": [ + { + "client_id": "217769178527-jaa8p8nfmic1j1065qs5a7vfqt18qec0.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "pokitmons.pokit", + "certificate_hash": "9ded6dcd446add68f506001f3c1b457cc1c3be9e" + } + }, + { + "client_id": "217769178527-mmbheg9v5npdhdrbfq78slpsk8lt2nga.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "pokitmons.pokit", + "certificate_hash": "b2f3e34f8e02d15beb0d10d3d48a05148e943642" + } + }, + { + "client_id": "217769178527-tslgsrrr1o8bli4hr4qnas2u9kg80a9h.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyDMh93QvJGUUX8-E-wyJoSS3cFrwfw8Q3w" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "217769178527-l4prj2q9qsuvkodc2cpi84psvul5rth2.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "217769178527-021j3dpbues9rhbkp6cffnn19mdajorq.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.pokitmons.pokit.App" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03a2ac03..352408fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ ().configureEach { + useJUnitPlatform() + } + namespace = "pokitmons.pokit.data" compileSdk = 34 @@ -38,6 +45,26 @@ dependencies { implementation(libs.appcompat) implementation(libs.material) testImplementation(libs.junit) + testImplementation(project(":feature:login")) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + + // kotest + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotlin.reflect) + + // hilt + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + // serialization + implementation(libs.kotlinx.serialization.json) + + // retrofit + implementation(libs.retrofit) + implementation(libs.retrofit.converter.serialization) + implementation(libs.okhttp) + implementation(libs.logging.interceptor) + + implementation(project(":domain")) } diff --git a/data/src/main/java/pokitmons/pokit/data/api/AuthApi.kt b/data/src/main/java/pokitmons/pokit/data/api/AuthApi.kt new file mode 100644 index 00000000..22fec397 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/api/AuthApi.kt @@ -0,0 +1,21 @@ +package pokitmons.pokit.data.api + +import pokitmons.pokit.data.model.auth.request.SNSLoginRequest +import pokitmons.pokit.data.model.auth.response.DuplicateNicknameResponse +import pokitmons.pokit.data.model.auth.response.SNSLoginResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface AuthApi { + @POST("auth/signin") + suspend fun snsLogin( + @Body snsLoginRequest: SNSLoginRequest, + ): SNSLoginResponse + + @GET("user/duplicate/{nickname}") + suspend fun checkDuplicateNickname( + @Path(value = "nickname") nickname: String, + ): DuplicateNicknameResponse +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/AuthDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/AuthDataSource.kt new file mode 100644 index 00000000..28f411c4 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/AuthDataSource.kt @@ -0,0 +1,11 @@ +package pokitmons.pokit.data.datasource.remote.auth + +import pokitmons.pokit.data.model.auth.request.SNSLoginRequest +import pokitmons.pokit.data.model.auth.response.DuplicateNicknameResponse +import pokitmons.pokit.data.model.auth.response.SNSLoginResponse + +interface AuthDataSource { +// suspend fun signUp(signUpRequest: SignUpRequest): PokitResponse + suspend fun snsLogin(snsLoginRequest: SNSLoginRequest): SNSLoginResponse + suspend fun checkDuplicateNickname(nickname: String): DuplicateNicknameResponse +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/RemoteAuthDataSourceImpl.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/RemoteAuthDataSourceImpl.kt new file mode 100644 index 00000000..7b1c521e --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/auth/RemoteAuthDataSourceImpl.kt @@ -0,0 +1,17 @@ +package pokitmons.pokit.data.datasource.remote.auth + +import pokitmons.pokit.data.api.AuthApi +import pokitmons.pokit.data.model.auth.request.SNSLoginRequest +import pokitmons.pokit.data.model.auth.response.DuplicateNicknameResponse +import pokitmons.pokit.data.model.auth.response.SNSLoginResponse +import javax.inject.Inject + +class RemoteAuthDataSourceImpl @Inject constructor(private val authApi: AuthApi) : AuthDataSource { + override suspend fun snsLogin(snsLoginRequest: SNSLoginRequest): SNSLoginResponse { + return authApi.snsLogin(snsLoginRequest) + } + + override suspend fun checkDuplicateNickname(nickname: String): DuplicateNicknameResponse { + return authApi.checkDuplicateNickname(nickname) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/auth/AuthModule.kt b/data/src/main/java/pokitmons/pokit/data/di/auth/AuthModule.kt new file mode 100644 index 00000000..62a641bf --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/auth/AuthModule.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.di.auth + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import pokitmons.pokit.data.datasource.remote.auth.AuthDataSource +import pokitmons.pokit.data.datasource.remote.auth.RemoteAuthDataSourceImpl +import pokitmons.pokit.data.repository.auth.AuthRepositoryImpl +import pokitmons.pokit.domain.repository.auth.AuthRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthModule { + @Binds + @Singleton + abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindAuthDataSource(authDataSourceImpl: RemoteAuthDataSourceImpl): AuthDataSource +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/network/BearerTokenInterceptor.kt b/data/src/main/java/pokitmons/pokit/data/di/network/BearerTokenInterceptor.kt new file mode 100644 index 00000000..769fc857 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/network/BearerTokenInterceptor.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.di.network + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.io.IOException + +// 토큰 api 수정될 때 까지 사용 +class BearerTokenInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithToken: Request = originalRequest.newBuilder() + .header( + "Authorization", + "Bearer eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzIxNjU4MjUxLCJleHAiOj" + + "MwMDE3MjE2NTgyNTF9.gw6LZimKLuZJ2y0UV5cgvk3F7o92pkRIDgx-qlD_S7qEI01QAFt9dZDyHADabftI" + ) + .build() + + return chain.proceed(requestWithToken) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt b/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt new file mode 100644 index 00000000..5d5371cf --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt @@ -0,0 +1,70 @@ +package pokitmons.pokit.data.di.network + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import pokitmons.pokit.data.api.AuthApi +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +private const val BASE_URL = "https://pokit.site" +private const val API = "api" +private const val VERSION = "v1" + +private const val READ_TIME_OUT = 20000L +private const val WRITE_TIME_OUT = 20000L + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Singleton + @Provides + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(BearerTokenInterceptor()) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + ) + .readTimeout(READ_TIME_OUT, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIME_OUT, TimeUnit.SECONDS) + .build() + } + + @Singleton + @Provides + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + coerceInputValues = true + prettyPrint = true + } + } + + @Singleton + @Provides + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json, + ): Retrofit { + val converterFactory = json.asConverterFactory("application/json; charset=UTF8".toMediaType()) + return Retrofit.Builder() + .baseUrl("$BASE_URL/$API/$VERSION/") + .addConverterFactory(converterFactory) + .client(okHttpClient) + .build() + } + + @Provides + fun provideAuthService(retrofit: Retrofit): AuthApi { + return retrofit.create(AuthApi::class.java) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/mapper/auth/AuthMapper.kt b/data/src/main/java/pokitmons/pokit/data/mapper/auth/AuthMapper.kt new file mode 100644 index 00000000..6168b2d5 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/mapper/auth/AuthMapper.kt @@ -0,0 +1,21 @@ +package pokitmons.pokit.data.mapper.auth + +import pokitmons.pokit.data.model.auth.response.DuplicateNicknameResponse +import pokitmons.pokit.data.model.auth.response.SNSLoginResponse +import pokitmons.pokit.domain.model.auth.DuplicateNicknameResult +import pokitmons.pokit.domain.model.auth.SNSLoginResult + +object AuthMapper { + fun mapperToSNSLogin(snsLoginResponse: SNSLoginResponse): SNSLoginResult { + return SNSLoginResult( + accessToken = snsLoginResponse.accessToken, + refreshToken = snsLoginResponse.refreshToken + ) + } + + fun mapperToDuplicateNickname(checkDuplicateNicknameResponse: DuplicateNicknameResponse): DuplicateNicknameResult { + return DuplicateNicknameResult( + isDuplicate = checkDuplicateNicknameResponse.isDuplicate + ) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/model/auth/request/SNSLoginRequest.kt b/data/src/main/java/pokitmons/pokit/data/model/auth/request/SNSLoginRequest.kt new file mode 100644 index 00000000..e52e5e4a --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/auth/request/SNSLoginRequest.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit.data.model.auth.request + +import kotlinx.serialization.Serializable + +@Serializable +data class SNSLoginRequest( + val authPlatform: String, + val idToken: String, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/auth/request/SignUpRequest.kt b/data/src/main/java/pokitmons/pokit/data/model/auth/request/SignUpRequest.kt new file mode 100644 index 00000000..016884a8 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/auth/request/SignUpRequest.kt @@ -0,0 +1,6 @@ +package pokitmons.pokit.data.model.auth.request + +data class SignUpRequest( + val nickname: String, + val interests: List, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/auth/response/DuplicateNicknameResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/auth/response/DuplicateNicknameResponse.kt new file mode 100644 index 00000000..3638dda8 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/auth/response/DuplicateNicknameResponse.kt @@ -0,0 +1,8 @@ +package pokitmons.pokit.data.model.auth.response + +import kotlinx.serialization.Serializable + +@Serializable +data class DuplicateNicknameResponse( + val isDuplicate: Boolean, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/auth/response/SNSLoginResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/auth/response/SNSLoginResponse.kt new file mode 100644 index 00000000..64d57407 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/auth/response/SNSLoginResponse.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit.data.model.auth.response + +import kotlinx.serialization.Serializable + +@Serializable +data class SNSLoginResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/auth/response/SignUpResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/auth/response/SignUpResponse.kt new file mode 100644 index 00000000..f98bdc98 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/auth/response/SignUpResponse.kt @@ -0,0 +1,5 @@ +package pokitmons.pokit.data.model.auth.response + +data class SignUpResponse( + val userId: Int, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/common/ParseErrorResult.kt b/data/src/main/java/pokitmons/pokit/data/model/common/ParseErrorResult.kt new file mode 100644 index 00000000..878e1220 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/common/ParseErrorResult.kt @@ -0,0 +1,18 @@ +package pokitmons.pokit.data.model.common + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import pokitmons.pokit.domain.commom.PokitError +import pokitmons.pokit.domain.commom.PokitResult + +fun parseErrorResult(throwable: Throwable): PokitResult { + return try { + val error: PokitErrorResponse = throwable.message?.let { errorBody -> + Json.decodeFromString(errorBody) + } ?: PokitErrorResponse() + val pokitError = PokitError(message = error.message, code = error.code) + PokitResult.Error(pokitError) + } catch (e: Exception) { + PokitResult.Error(PokitError()) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/model/common/PokitErrorResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/common/PokitErrorResponse.kt new file mode 100644 index 00000000..93f89d8c --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/common/PokitErrorResponse.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit.data.model.common + +import kotlinx.serialization.Serializable + +@Serializable +data class PokitErrorResponse( + val message: String = "그 외", + val code: String = "U_0000", +) diff --git a/data/src/main/java/pokitmons/pokit/data/repository/auth/AuthRepositoryImpl.kt b/data/src/main/java/pokitmons/pokit/data/repository/auth/AuthRepositoryImpl.kt new file mode 100644 index 00000000..ff31557f --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/repository/auth/AuthRepositoryImpl.kt @@ -0,0 +1,41 @@ +package pokitmons.pokit.data.repository.auth + +import pokitmons.pokit.data.datasource.remote.auth.AuthDataSource +import pokitmons.pokit.data.mapper.auth.AuthMapper +import pokitmons.pokit.data.model.auth.request.SNSLoginRequest +import pokitmons.pokit.data.model.auth.response.DuplicateNicknameResponse +import pokitmons.pokit.data.model.auth.response.SNSLoginResponse +import pokitmons.pokit.data.model.common.parseErrorResult +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.auth.DuplicateNicknameResult +import pokitmons.pokit.domain.model.auth.SNSLoginResult +import pokitmons.pokit.domain.repository.auth.AuthRepository +import javax.inject.Inject + +// TODO getOrElse 반복되는 로직 함수화 +class AuthRepositoryImpl @Inject constructor( + private val remoteAuthDataSource: AuthDataSource, +) : AuthRepository { + override suspend fun snsLogin( + authPlatform: String, + idToken: String, + ): PokitResult { + return runCatching { + val snsLoginResponse: SNSLoginResponse = remoteAuthDataSource.snsLogin(SNSLoginRequest(authPlatform = authPlatform, idToken = idToken)) + val snsLoginMapper = AuthMapper.mapperToSNSLogin(snsLoginResponse) + PokitResult.Success(snsLoginMapper) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun checkDuplicateNickname(nickname: String): PokitResult { + return runCatching { + val checkDuplicateNicknameResponse: DuplicateNicknameResponse = remoteAuthDataSource.checkDuplicateNickname(nickname) + val checkDuplicateMapper = AuthMapper.mapperToDuplicateNickname(checkDuplicateNicknameResponse) + PokitResult.Success(checkDuplicateMapper) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } +} diff --git a/data/src/test/java/pokitmons/pokit/data/ExampleUnitTest.kt b/data/src/test/java/pokitmons/pokit/data/ExampleUnitTest.kt deleted file mode 100644 index c174d0bd..00000000 --- a/data/src/test/java/pokitmons/pokit/data/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package pokitmons.pokit.data - -import junit.framework.TestCase.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index a64717b1..9f63a980 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -7,3 +7,7 @@ java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + +dependencies { + implementation(libs.javax.inject) +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/MyClass.kt b/domain/src/main/java/pokitmons/pokit/domain/MyClass.kt deleted file mode 100644 index 3a2d8c51..00000000 --- a/domain/src/main/java/pokitmons/pokit/domain/MyClass.kt +++ /dev/null @@ -1,3 +0,0 @@ -package pokitmons.pokit.domain - -class MyClass diff --git a/domain/src/main/java/pokitmons/pokit/domain/commom/PokitError.kt b/domain/src/main/java/pokitmons/pokit/domain/commom/PokitError.kt new file mode 100644 index 00000000..79e9632e --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/commom/PokitError.kt @@ -0,0 +1,6 @@ +package pokitmons.pokit.domain.commom + +data class PokitError( + val message: String = "그 외", + val code: String = "U_0000", +) diff --git a/domain/src/main/java/pokitmons/pokit/domain/commom/PokitResult.kt b/domain/src/main/java/pokitmons/pokit/domain/commom/PokitResult.kt new file mode 100644 index 00000000..25ea983a --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/commom/PokitResult.kt @@ -0,0 +1,6 @@ +package pokitmons.pokit.domain.commom + +sealed interface PokitResult { + data class Success(val result: T) : PokitResult + data class Error(val error: PokitError) : PokitResult +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/model/auth/DuplicateNicknameResult.kt b/domain/src/main/java/pokitmons/pokit/domain/model/auth/DuplicateNicknameResult.kt new file mode 100644 index 00000000..34c8618c --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/model/auth/DuplicateNicknameResult.kt @@ -0,0 +1,5 @@ +package pokitmons.pokit.domain.model.auth + +data class DuplicateNicknameResult( + val isDuplicate: Boolean, +) diff --git a/domain/src/main/java/pokitmons/pokit/domain/model/auth/SNSLoginResult.kt b/domain/src/main/java/pokitmons/pokit/domain/model/auth/SNSLoginResult.kt new file mode 100644 index 00000000..79bd155a --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/model/auth/SNSLoginResult.kt @@ -0,0 +1,6 @@ +package pokitmons.pokit.domain.model.auth + +data class SNSLoginResult( + val accessToken: String, + val refreshToken: String, +) diff --git a/domain/src/main/java/pokitmons/pokit/domain/repository/auth/AuthRepository.kt b/domain/src/main/java/pokitmons/pokit/domain/repository/auth/AuthRepository.kt new file mode 100644 index 00000000..f50578d8 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/repository/auth/AuthRepository.kt @@ -0,0 +1,14 @@ +package pokitmons.pokit.domain.repository.auth + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.auth.DuplicateNicknameResult +import pokitmons.pokit.domain.model.auth.SNSLoginResult + +interface AuthRepository { + suspend fun snsLogin( + authPlatform: String, + idToken: String, + ): PokitResult + + suspend fun checkDuplicateNickname(nickname: String): PokitResult +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/InputNicknameUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/InputNicknameUseCase.kt new file mode 100644 index 00000000..15881f7f --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/InputNicknameUseCase.kt @@ -0,0 +1,15 @@ +package pokitmons.pokit.domain.usecase.auth + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.auth.DuplicateNicknameResult +import pokitmons.pokit.domain.repository.auth.AuthRepository +import javax.inject.Inject + +class InputNicknameUseCase @Inject constructor(private val authRepository: AuthRepository) { + suspend fun checkDuplicateNickname(nickname: String): PokitResult { + return when (val duplicateResult = authRepository.checkDuplicateNickname(nickname)) { + is PokitResult.Success -> PokitResult.Success(duplicateResult.result) + is PokitResult.Error -> PokitResult.Error(duplicateResult.error) + } + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SNSLoginUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SNSLoginUseCase.kt new file mode 100644 index 00000000..b16da180 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SNSLoginUseCase.kt @@ -0,0 +1,15 @@ +package pokitmons.pokit.domain.usecase.auth + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.auth.SNSLoginResult +import pokitmons.pokit.domain.repository.auth.AuthRepository +import javax.inject.Inject + +class SNSLoginUseCase @Inject constructor(private val authRepository: AuthRepository) { + suspend fun snsLogin(authPlatform: String, idToken: String): PokitResult { + return when (val loginResult = authRepository.snsLogin(authPlatform, idToken)) { + is PokitResult.Success -> PokitResult.Success(loginResult.result) + is PokitResult.Error -> PokitResult.Error(loginResult.error) + } + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SignUpUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SignUpUseCase.kt new file mode 100644 index 00000000..029c6c3f --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/auth/SignUpUseCase.kt @@ -0,0 +1,3 @@ +package pokitmons.pokit.domain.usecase.auth + +class SignUpUseCase diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts index 4188ee20..6ec95ebb 100644 --- a/feature/login/build.gradle.kts +++ b/feature/login/build.gradle.kts @@ -4,6 +4,8 @@ import java.util.Properties plugins { alias(libs.plugins.com.android.library) alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + id("kotlin-kapt") } val properties = Properties() @@ -79,9 +81,24 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + // google login implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) implementation(libs.googleid) + // hilt + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + // navigation + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + + // firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth.ktx) + + // module implementation(project(":core:ui")) + implementation(project(":domain")) } diff --git a/feature/login/src/main/java/pokitmons/pokit/LoginState.kt b/feature/login/src/main/java/pokitmons/pokit/LoginState.kt new file mode 100644 index 00000000..687af968 --- /dev/null +++ b/feature/login/src/main/java/pokitmons/pokit/LoginState.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit + +import pokitmons.pokit.domain.commom.PokitError + +sealed class LoginState { + data object Init : LoginState() + data object Login : LoginState() + data class Failed(val error: PokitError) : LoginState() +} diff --git a/feature/login/src/main/java/pokitmons/pokit/LoginViewModel.kt b/feature/login/src/main/java/pokitmons/pokit/LoginViewModel.kt new file mode 100644 index 00000000..581c9681 --- /dev/null +++ b/feature/login/src/main/java/pokitmons/pokit/LoginViewModel.kt @@ -0,0 +1,78 @@ +package pokitmons.pokit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.auth.SNSLoginUseCase +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginUseCase: SNSLoginUseCase, +) : ViewModel() { + + private var apiRequestJob: Job? = null + + private val _loginState: MutableStateFlow = MutableStateFlow(LoginState.Init) + val loginState: StateFlow + get() = _loginState.asStateFlow() + + private val _inputNicknameState = MutableStateFlow("") + val inputNicknameState: StateFlow + get() = _inputNicknameState.asStateFlow() + + fun inputText(text: String) { + _inputNicknameState.value = text + } + + fun snsLogin(authPlatform: String, idToken: String) { + viewModelScope.launch { + val loginResult = loginUseCase.snsLogin( + authPlatform = authPlatform, + idToken = idToken + ) + + when (loginResult) { + is PokitResult.Success -> { + accessToken = loginResult.result.accessToken + refreshToken = loginResult.result.refreshToken + _loginState.emit(LoginState.Login) + } + is PokitResult.Error -> _loginState.emit(LoginState.Failed(loginResult.error)) + } + } + } + + fun checkDuplicateNickname(nickname: String) { + apiRequestJob?.cancel() + apiRequestJob = viewModelScope.launch { + delay(1.second()) + // TOOD api 연동 + } + } + + val categories: ArrayList = arrayListOf() + + var accessToken: String = "" + private set + + var refreshToken: String = "" + private set + + var nickname: String = "" + private set + + // TODO 확장함수 모듈 생성하기 + companion object { + private fun Int.second(): Long { + return (this * 1000L) + } + } +} diff --git a/feature/login/src/main/java/pokitmons/pokit/keyword/KeywordScreen.kt b/feature/login/src/main/java/pokitmons/pokit/keyword/KeywordScreen.kt index 3a8f2731..2f3e410f 100644 --- a/feature/login/src/main/java/pokitmons/pokit/keyword/KeywordScreen.kt +++ b/feature/login/src/main/java/pokitmons/pokit/keyword/KeywordScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import pokitmons.pokit.LoginViewModel import pokitmons.pokit.core.ui.components.atom.button.PokitButton import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonSize import pokitmons.pokit.core.ui.components.atom.chip.PokitChip @@ -26,6 +27,7 @@ import pokitmons.pokit.login.R as Login @Composable fun KeywordScreen( + loginViewModel: LoginViewModel, onNavigateToSignUpScreen: () -> Unit, popBackStack: () -> Unit, ) { diff --git a/feature/login/src/main/java/pokitmons/pokit/login/LoginScreen.kt b/feature/login/src/main/java/pokitmons/pokit/login/LoginScreen.kt index f869ed43..88213c07 100644 --- a/feature/login/src/main/java/pokitmons/pokit/login/LoginScreen.kt +++ b/feature/login/src/main/java/pokitmons/pokit/login/LoginScreen.kt @@ -1,7 +1,8 @@ package pokitmons.pokit.login -import android.annotation.SuppressLint -import android.util.Log +import android.app.Activity +import android.content.Context +import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -9,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,16 +22,34 @@ import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.OAuthProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import pokitmons.pokit.LoginState +import pokitmons.pokit.LoginViewModel import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitLoginButtonType import pokitmons.pokit.core.ui.components.atom.loginbutton.PokitLoginButton @Composable fun LoginScreen( + loginViewModel: LoginViewModel, onNavigateToTermsOfServiceScreen: () -> Unit, - onNavigateToMainScreen: () -> Unit, ) { - // TODO 서버 api 개발완료 후 viewmodel 연동 및 아키텍처 구축 + val loginState by loginViewModel.loginState.collectAsState() + val context: Context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + when (loginState) { + is LoginState.Init -> Unit + is LoginState.Login -> onNavigateToTermsOfServiceScreen() + is LoginState.Failed -> { + // TODO 로그인 실패 바텀시트 렌더링 + Toast.makeText(context, (loginState as LoginState.Failed).error.toString(), Toast.LENGTH_SHORT).show() + } + } + Box( modifier = Modifier .fillMaxSize() @@ -41,7 +62,12 @@ fun LoginScreen( PokitLoginButton( loginType = PokitLoginButtonType.APPLE, text = stringResource(id = R.string.apple_login), - onClick = { onNavigateToMainScreen() } + onClick = { + appleLogin( + context = context, + snsLogin = loginViewModel::snsLogin + ) + } ) Spacer(modifier = Modifier.height(8.dp)) @@ -49,18 +75,24 @@ fun LoginScreen( PokitLoginButton( loginType = PokitLoginButtonType.GOOGLE, text = stringResource(id = R.string.google_login), - onClick = { onNavigateToTermsOfServiceScreen() } + onClick = { +// onNavigateToTermsOfServiceScreen() + googleLogin( + snsLogin = loginViewModel::snsLogin, + coroutineScope = coroutineScope, + context = context + ) + } ) } } } -@SuppressLint("CoroutineCreationDuringComposition") -@Composable -private fun googleLogin() { - val coroutineScope = rememberCoroutineScope() - - val context = LocalContext.current +private fun googleLogin( + snsLogin: (String, String) -> Unit, + coroutineScope: CoroutineScope, + context: Context, +) { val credentialManager = CredentialManager.create(context) val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder() @@ -81,9 +113,54 @@ private fun googleLogin() { ) val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(result.credential.data) val googleIdToken = googleIdTokenCredential.idToken - Log.d("success: ", googleIdToken) + snsLogin("구글", googleIdToken) } catch (e: Exception) { - Log.d("failed : ", e.message.toString()) + // TODO 로그인 실패 바텀시트 렌더링 + } + } +} + +private fun appleLogin( + context: Context, + snsLogin: (String, String) -> Unit, +) { + val provider = OAuthProvider.newBuilder("apple.com").apply { + addCustomParameter("locale", "ko") + } + + // 이미 응답을 수신 했는지 확인 + val auth = FirebaseAuth.getInstance() + val pending = auth.pendingAuthResult + if (pending != null) { + pending.addOnSuccessListener { authResult -> + handleAuthResult(authResult, snsLogin) + }.addOnFailureListener { e -> + // TODO 로그인 실패 바텀시트 렌더링 + } + } else { + auth.startActivityForSignInWithProvider(context as Activity, provider.build()).addOnSuccessListener { authResult -> + handleAuthResult(authResult, snsLogin) + }.addOnFailureListener { + // TODO 로그인 실패 바텀시트 렌더링 + } + } +} + +private fun handleAuthResult( + authResult: AuthResult, + snsLogin: (String, String) -> Unit, +) { + val user = authResult.user + user?.getIdToken(true)?.addOnCompleteListener { task -> + if (task.isSuccessful) { + val idToken = task.result?.token + if (idToken != null) { + snsLogin("애플", idToken) + } else { + // TODO 로그인 실패 바텀시트 렌더링 + } + } else { + // TODO 로그인 실패 바텀시트 렌더링 } } } diff --git a/feature/login/src/main/java/pokitmons/pokit/navigation/LoginNavHost.kt b/feature/login/src/main/java/pokitmons/pokit/navigation/LoginNavHost.kt index a6285297..41a77322 100644 --- a/feature/login/src/main/java/pokitmons/pokit/navigation/LoginNavHost.kt +++ b/feature/login/src/main/java/pokitmons/pokit/navigation/LoginNavHost.kt @@ -1,9 +1,11 @@ package pokitmons.pokit.navigation import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import pokitmons.pokit.LoginViewModel import pokitmons.pokit.keyword.KeywordScreen import pokitmons.pokit.login.LoginScreen import pokitmons.pokit.nickname.InputNicknameScreen @@ -13,14 +15,15 @@ import pokitmons.pokit.terms.TermsOfServiceScreen @Composable fun LoginNavHost() { val navController = rememberNavController() + val loginViewModel: LoginViewModel = hiltViewModel() NavHost( navController = navController, startDestination = LoginRoute.LoginScreen.name ) { composable(route = LoginRoute.LoginScreen.name) { LoginScreen( - onNavigateToTermsOfServiceScreen = { navController.navigate(route = LoginRoute.TermsOfServiceScreen.name) }, - onNavigateToMainScreen = { } // TODO 메인 화면 구현후 수정 + loginViewModel = loginViewModel, + onNavigateToTermsOfServiceScreen = { navController.navigate(route = LoginRoute.TermsOfServiceScreen.name) } ) } @@ -33,6 +36,7 @@ fun LoginNavHost() { composable(route = LoginRoute.InputNicknameScreen.name) { InputNicknameScreen( + loginViewModel = loginViewModel, onNavigateToKeywordScreen = { navController.navigate(route = LoginRoute.KeywordScreen.name) }, popBackStack = { navController.popBackStack() } ) @@ -40,6 +44,7 @@ fun LoginNavHost() { composable(route = LoginRoute.KeywordScreen.name) { KeywordScreen( + loginViewModel = loginViewModel, onNavigateToSignUpScreen = { navController.navigate(route = LoginRoute.SignUpSuccessScreen.name) }, popBackStack = { navController.popBackStack() } ) @@ -47,6 +52,7 @@ fun LoginNavHost() { composable(route = LoginRoute.SignUpSuccessScreen.name) { SignUpSuccessScreen( + loginViewModel = loginViewModel, onNavigateToMainScreen = { } // TODO 메인 화면 구현후 수정 ) } diff --git a/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameScreen.kt b/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameScreen.kt index 0ae25982..eba8de5c 100644 --- a/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameScreen.kt +++ b/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon @@ -18,7 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import pokitmons.pokit.LoginViewModel import pokitmons.pokit.core.ui.components.atom.button.PokitButton import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonSize import pokitmons.pokit.core.ui.components.block.labeledinput.LabeledInput @@ -31,33 +32,31 @@ private const val NICKNAME_MIN_LENGTH = 1 // TODO 매직넘버를 포함하는 @Composable fun InputNicknameScreen( + loginViewModel: LoginViewModel, onNavigateToKeywordScreen: () -> Unit, popBackStack: () -> Unit, ) { - val inputNicknameViewModel: InputNicknameViewModel = viewModel() // TODO hiltViewModel 마이그레이션 예정 - val inputNicknameState by inputNicknameViewModel.inputNicknameState.collectAsState() + val inputNicknameState by loginViewModel.inputNicknameState.collectAsState() Box( modifier = Modifier .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 28.dp) .fillMaxSize() ) { - Column() { + Column { Icon( modifier = Modifier.clickable { popBackStack() }, painter = painterResource(id = UI.drawable.icon_24_arrow_left), contentDescription = "뒤로가기" ) - Spacer(modifier = Modifier.padding(top = 32.dp)) + Spacer(modifier = Modifier.height(32.dp)) Text( style = PokitTheme.typography.title1, text = stringResource(id = Login.string.input_nickname_title) ) - Spacer(modifier = Modifier.padding(top = 28.dp)) - LabeledInput( modifier = Modifier .fillMaxWidth() @@ -74,7 +73,7 @@ fun InputNicknameScreen( hintText = stringResource(id = Login.string.input_nickname_hint), onChangeText = { text -> if (text.length <= NICKNAME_MAX_LENGTH) { - inputNicknameViewModel.inputText(text) + loginViewModel.inputText(text) } } ) diff --git a/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameViewModel.kt b/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameViewModel.kt deleted file mode 100644 index 0b7678e8..00000000 --- a/feature/login/src/main/java/pokitmons/pokit/nickname/InputNicknameViewModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package pokitmons.pokit.nickname - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class InputNicknameViewModel : ViewModel() { - private val _inputNicknameState = MutableStateFlow("") - val inputNicknameState: StateFlow - get() = _inputNicknameState - - fun inputText(text: String) { - _inputNicknameState.value = text - } -} diff --git a/feature/login/src/main/java/pokitmons/pokit/success/SignUpSuccessScreen.kt b/feature/login/src/main/java/pokitmons/pokit/success/SignUpSuccessScreen.kt index dd195ddc..ff530988 100644 --- a/feature/login/src/main/java/pokitmons/pokit/success/SignUpSuccessScreen.kt +++ b/feature/login/src/main/java/pokitmons/pokit/success/SignUpSuccessScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import pokitmons.pokit.LoginViewModel import pokitmons.pokit.core.ui.components.atom.button.PokitButton import pokitmons.pokit.core.ui.components.atom.button.attributes.PokitButtonSize import pokitmons.pokit.core.ui.theme.PokitTheme @@ -26,6 +27,7 @@ import pokitmons.pokit.login.R @Composable fun SignUpSuccessScreen( + loginViewModel: LoginViewModel, onNavigateToMainScreen: () -> Unit, ) { Box( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef8751e5..7e65bb1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,9 @@ [versions] agp = "8.3.2" +javax-inject = "1" +kotest-runner-junit5 = "5.9.1" +kotlin-reflect = "1.9.23" +logging-interceptor = "4.11.0" org-jetbrains-kotlin-android = "1.9.0" core-ktx = "1.13.1" junit = "4.13.2" @@ -22,11 +26,15 @@ annotation = "1.8.0" ktlint = "11.3.2" androidx-credentials = "1.2.2" androidx-credentials-play-services-auth = "1.2.2" -googleid = "1.1.0" +googleid = "1.1.1" +firebase-bom = "33.1.2" +firebase-auth = "23.0.0" +google-services = "4.4.2" [libraries] androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle-runtime-ktx"} androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javax-inject" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } @@ -44,9 +52,12 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio 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-play-services-auth" } googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } - - +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-auth-ktx = { module = "com.google.firebase:firebase-auth-ktx", version.ref = "firebase-auth" } datastore = { module = "androidx.datastore:datastore-preferences", version = "1.1.1" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-runner-junit5" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-reflect" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "logging-interceptor" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version = "1.0.0"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0"} @@ -84,6 +95,7 @@ kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "org-jetbrains-k kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "org-jetbrains-kotlin-android"} com-android-dynamic-feature = { id = "com.android.dynamic-feature", version.ref = "agp" } ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +googleServices = { id = "com.google.gms.google-services", version.ref = "google-services" }