diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 3cdcf2d..21f9538 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -37,7 +37,10 @@ jobs: cache: gradle - name: Run tests - run: ./gradlew test + run: | + echo sdk.dir=~/Android/Sdk >> local.properties + echo api_key=\"${{ secrets.API_KEY }}\" >> local.properties + ./gradlew test - name: Upload test report uses: actions/upload-artifact@v4 @@ -110,6 +113,7 @@ jobs: - name: Grant execute permission for gradlew run: | echo sdk.dir=~/Android/Sdk >> local.properties + echo api_key=\"${{ secrets.API_KEY }}\" >> local.properties chmod +x gradlew - name: Build with Gradle run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cfdf6da..ca30711 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,8 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + lintOptions.disable("Instantiatable") + lintOptions.isAbortOnError = false } dependencies { @@ -75,6 +77,8 @@ dependencies { implementation(libs.converter.gson) implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) + ksp(libs.dagger.compiler) // Dagger compiler + ksp(libs.hilt.compiler) // Hilt compiler implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.navigation.compose) implementation(libs.orbit.viewmodel) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b30151..4f77ef4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + diff --git a/app/src/main/java/com.example.app/App.kt b/app/src/main/java/com.example.app/App.kt new file mode 100644 index 0000000..c3fbbce --- /dev/null +++ b/app/src/main/java/com.example.app/App.kt @@ -0,0 +1,7 @@ +package com.example.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App : Application() diff --git a/data/build.gradle.kts b/data/build.gradle.kts index f7d754f..10b1b2a 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -5,6 +7,9 @@ plugins { id("com.google.devtools.ksp") } +val properties = Properties() +properties.load(project.rootProject.file("local.properties").inputStream()) + android { namespace = "com.example.data" compileSdk = 34 @@ -14,6 +19,9 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "api_key", properties["api_key"] as String) + manifestPlaceholders["api_key"] = properties["api_key"] as String } buildTypes { @@ -32,6 +40,11 @@ android { kotlinOptions { jvmTarget = "17" } + buildFeatures { + buildConfig = true + } + lintOptions.disable("Instantiatable") + lintOptions.isAbortOnError = false } dependencies { @@ -48,4 +61,12 @@ dependencies { ksp(libs.hilt.android.compiler) implementation(project(":domain")) + + //retrofit + implementation(libs.retrofit) + implementation(libs.converter.gson) + + //okhttp + implementation(libs.okhttp) + implementation(libs.logging.interceptor) } diff --git a/data/src/main/java/com/example/data/di/RetrofitModule.kt b/data/src/main/java/com/example/data/di/RetrofitModule.kt new file mode 100644 index 0000000..fac8960 --- /dev/null +++ b/data/src/main/java/com/example/data/di/RetrofitModule.kt @@ -0,0 +1,40 @@ +package com.example.data.di + +import com.example.data.BuildConfig +import com.example.data.retrofit.UserService +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +@Module +@InstallIn(SingletonComponent::class) +class RetrofitModule { + + @Provides + fun provideOkHttp(): OkHttpClient { + return OkHttpClient + .Builder() + .build() + } + + @Provides + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + val gson = GsonBuilder() + .setLenient() + .create(); + return Retrofit.Builder() + .baseUrl("${BuildConfig.api_key}/api/") + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(okHttpClient) + .build() + } + + @Provides + fun provideUserService(retrofit: Retrofit): UserService = retrofit.create(UserService::class.java) + +} diff --git a/data/src/main/java/com/example/data/di/UserModule.kt b/data/src/main/java/com/example/data/di/UserModule.kt new file mode 100644 index 0000000..d339641 --- /dev/null +++ b/data/src/main/java/com/example/data/di/UserModule.kt @@ -0,0 +1,16 @@ +package com.example.data.di + +import com.example.data.usecase.LoginUseCaseImpl +import com.example.domain.usecase.login.LoginUseCase +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class UserModule { + + @Binds + abstract fun bindLoginUseCase(loginUseCase: LoginUseCaseImpl) : LoginUseCase +} diff --git a/data/src/main/java/com/example/data/model/CommonResponse.kt b/data/src/main/java/com/example/data/model/CommonResponse.kt new file mode 100644 index 0000000..d242426 --- /dev/null +++ b/data/src/main/java/com/example/data/model/CommonResponse.kt @@ -0,0 +1,8 @@ +package com.example.data.model + +data class CommonResponse( + val result: String, + val data: T, + val errorCode: String, + val errorMessage: String, +) diff --git a/data/src/main/java/com/example/data/model/LoginParam.kt b/data/src/main/java/com/example/data/model/LoginParam.kt new file mode 100644 index 0000000..e914673 --- /dev/null +++ b/data/src/main/java/com/example/data/model/LoginParam.kt @@ -0,0 +1,17 @@ +package com.example.data.model + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody + +data class LoginParam( + @SerializedName("loginId") + val loginId: String, + @SerializedName("password") + val password: String, +) { + fun toRequestBody(): RequestBody { + return Gson().toJson(this).toRequestBody() + } +} diff --git a/data/src/main/java/com/example/data/retrofit/UserService.kt b/data/src/main/java/com/example/data/retrofit/UserService.kt new file mode 100644 index 0000000..17bea0c --- /dev/null +++ b/data/src/main/java/com/example/data/retrofit/UserService.kt @@ -0,0 +1,15 @@ +package com.example.data.retrofit + +import com.example.data.model.CommonResponse +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.Headers +import retrofit2.http.POST + +interface UserService { + @POST("users/login") + @Headers("Content-Type:application/json; charset=UTF-8") + suspend fun login( + @Body requestBody: RequestBody + ) : CommonResponse +} diff --git a/data/src/main/java/com/example/data/usecase/LoginUseCaseImpl.kt b/data/src/main/java/com/example/data/usecase/LoginUseCaseImpl.kt new file mode 100644 index 0000000..745c0de --- /dev/null +++ b/data/src/main/java/com/example/data/usecase/LoginUseCaseImpl.kt @@ -0,0 +1,16 @@ +package com.example.data.usecase + +import com.example.data.model.LoginParam +import com.example.data.retrofit.UserService +import com.example.domain.usecase.login.LoginUseCase +import javax.inject.Inject +import okhttp3.RequestBody + +class LoginUseCaseImpl @Inject constructor( + private val userService: UserService +): LoginUseCase { + override suspend fun invoke(id: String, password: String): Result = runCatching { + val requestBody = LoginParam(id, password).toRequestBody() + userService.login(requestBody).data + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 0178130..89d6536 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -1,8 +1,13 @@ +import java.util.Properties + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") } +val properties = Properties() +properties.load(project.rootProject.file("local.properties").inputStream()) + android { namespace = "com.example.domain" compileSdk = 34 @@ -12,6 +17,9 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "api_key", properties["api_key"] as String) + manifestPlaceholders["api_key"] = properties["api_key"] as String } buildTypes { @@ -30,6 +38,11 @@ android { kotlinOptions { jvmTarget = "17" } + buildFeatures { + buildConfig = true + } + lintOptions.disable("Instantiatable") + lintOptions.isAbortOnError = false } dependencies { @@ -41,4 +54,4 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f544b4..d8a4dbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,10 @@ lifecycle-runtime-ktx = "2.7.0" material = "1.11.0" navigation-compose = "2.7.7" +okhttp = "4.12.0" +orbit-compose = "7.0.1" +orbit-core = "7.0.1" +orbit-test = "7.0.1" orbit-viewmodel = "7.0.1" paging-compose = "3.3.0-beta01" paging-runtime = "3.2.1" @@ -47,11 +51,14 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room-runtime" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converter-gson" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "hilt-android" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" } junit = { module = "junit:junit", version.ref = "junit" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } material = { module = "com.google.android.material:material", version.ref = "material" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } androidx-compose-ui= { group = "androidx.compose.ui", name= "ui" } androidx-compose-ui-graphics= { group = "androidx.compose.ui", name= "ui-graphics" } @@ -61,6 +68,10 @@ androidx-compose-compose-bom = {group= "androidx.compose", name = "compose-bom", androidx-compose-ui-ui-test-junit4 = {group="androidx.compose.ui", name="ui-test-junit4"} androidx-compose-ui-ui-tooling = {group="androidx.compose.ui", name="ui-tooling"} androidx-compose-ui-ui-test-manifest = {group="androidx.compose.ui", name="ui-test-manifest"} +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbit-compose" } +orbit-core = { module = "org.orbit-mvi:orbit-core", version.ref = "orbit-core" } +orbit-test = { module = "org.orbit-mvi:orbit-test", version.ref = "orbit-test" } orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbit-viewmodel" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index ba91a73..68ac01f 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.google.devtools.ksp") alias(libs.plugins.ktlint) alias(libs.plugins.kotlin.serialization) + id("com.google.dagger.hilt.android") } android { @@ -41,6 +42,8 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.0" } + lintOptions.disable("Instantiatable") + lintOptions.isAbortOnError = false } dependencies { @@ -67,5 +70,11 @@ dependencies { ksp(libs.hilt.android.compiler) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.navigation.compose) + ksp(libs.dagger.compiler) // Dagger compiler + ksp(libs.hilt.compiler) // Hilt compiler implementation(project(":domain")) + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) + testImplementation(libs.orbit.test) } diff --git a/presentation/src/main/java/com/example/presentation/component/LoginTextField.kt b/presentation/src/main/java/com/example/presentation/component/LoginTextField.kt index 4304c21..774c9a1 100644 --- a/presentation/src/main/java/com/example/presentation/component/LoginTextField.kt +++ b/presentation/src/main/java/com/example/presentation/component/LoginTextField.kt @@ -7,12 +7,14 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @Composable fun LoginTextField( modifier: Modifier, value: String, + visualTransformation: VisualTransformation = VisualTransformation.None, onValueString: (String) -> Unit, ) { TextField( @@ -27,5 +29,6 @@ fun LoginTextField( disabledIndicatorColor = Color.Transparent, ), shape = RoundedCornerShape(8.dp), + visualTransformation = visualTransformation, ) } diff --git a/presentation/src/main/java/com/example/presentation/login/LoginActivity.kt b/presentation/src/main/java/com/example/presentation/login/LoginActivity.kt index 65942e0..93651ca 100644 --- a/presentation/src/main/java/com/example/presentation/login/LoginActivity.kt +++ b/presentation/src/main/java/com/example/presentation/login/LoginActivity.kt @@ -4,7 +4,9 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import com.example.presentation.ui.theme.SnsProjectTheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class LoginActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/presentation/src/main/java/com/example/presentation/login/LoginScreen.kt b/presentation/src/main/java/com/example/presentation/login/LoginScreen.kt index f270d73..71d3d41 100644 --- a/presentation/src/main/java/com/example/presentation/login/LoginScreen.kt +++ b/presentation/src/main/java/com/example/presentation/login/LoginScreen.kt @@ -1,5 +1,7 @@ package com.example.presentation.login +import android.util.Log +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,21 +14,36 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.example.presentation.component.LoginTextField import com.example.presentation.component.SubmitButton import com.example.presentation.ui.theme.SnsProjectTheme +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun LoginScreen(viewmodel: LoginViewModel = hiltViewModel()) { + val state = viewmodel.collectAsState().value + val context = LocalContext.current + viewmodel.collectSideEffect { sideEffect -> + when (sideEffect) { + is LoginSideEffect.Toast -> { + Log.e("LoginScreen", "${sideEffect.message}") + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + } LoginScreen( - id = "", - password = "", - onIdChange = {}, - passwordChange = {}, - onNavigationToSignUpScreen = { viewmodel.onLoginClick() }, + id = state.id, + password = state.password, + onIdChange = viewmodel::onChangeId, + passwordChange = viewmodel::onChangePassword, + onNavigationToSignUpScreen = { }, + onLoginClick = viewmodel::onLoginClick, ) } @@ -37,6 +54,7 @@ fun LoginScreen( onIdChange: (String) -> Unit, passwordChange: (String) -> Unit, onNavigationToSignUpScreen: () -> Unit, + onLoginClick: () -> Unit, ) { Surface { Column( @@ -88,6 +106,7 @@ fun LoginScreen( .padding(8.dp) .fillMaxWidth(), value = password, + visualTransformation = PasswordVisualTransformation(), onValueString = passwordChange, ) @@ -97,7 +116,7 @@ fun LoginScreen( .padding(top = 24.dp) .fillMaxWidth(), text = "로그인", - onClick = {}, + onClick = onLoginClick, ) Spacer(modifier = Modifier.weight(1f)) @@ -120,6 +139,6 @@ fun LoginScreen( @Composable private fun LoginScreenPreview() { SnsProjectTheme { - LoginScreen("id", "password", {}, {}, onNavigationToSignUpScreen = {}) + LoginScreen("id", "password", {}, {}, onNavigationToSignUpScreen = {}, {}) } } diff --git a/presentation/src/main/java/com/example/presentation/login/LoginViewModel.kt b/presentation/src/main/java/com/example/presentation/login/LoginViewModel.kt index 9330f3f..3567649 100644 --- a/presentation/src/main/java/com/example/presentation/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/example/presentation/login/LoginViewModel.kt @@ -1,23 +1,68 @@ package com.example.presentation.login +import android.util.Log +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.example.domain.usecase.login.LoginUseCase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineExceptionHandler +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container @HiltViewModel class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase, - ) : ViewModel() { - fun onLoginClick() { - val id = "" - val pwd = "" - viewModelScope.launch { - loginUseCase(id, pwd) + ) : ViewModel(), ContainerHost { + override val container: Container = + container( + initialState = LoginState(), + buildSettings = { + this.exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + intent { + postSideEffect(LoginSideEffect.Toast(message = throwable.message ?: "")) + } + } + }, + ) + + fun onLoginClick() = + intent { + val id = state.id + val pwd = state.password + val token: String = loginUseCase(id, pwd).getOrThrow() + Log.e("LoginViewModel", "$token") + postSideEffect(LoginSideEffect.Toast(message = "token = $token")) + } + + fun onChangeId(id: String) = + intent { + reduce { + state.copy(id = id) + } + } + + fun onChangePassword(password: String) = + intent { + reduce { + state.copy(password = password) + } } - } } + +@Immutable +data class LoginState( + val id: String = "", + val password: String = "", +) + +sealed interface LoginSideEffect { + class Toast(val message: String) : LoginSideEffect +}