diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f851d74..eecd9ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,15 +1,18 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + id("kotlin-kapt") + alias(libs.plugins.hilt.android) + id("org.jlleitschuh.gradle.ktlint") version "11.6.1" } android { namespace = "kr.ac.anu.mumu" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "kr.ac.anu.mumu" - minSdk = 24 + minSdk = 26 targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -33,6 +36,9 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + viewBinding = true + } } dependencies { @@ -45,4 +51,23 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file + + // Retrofit & GSON + implementation(libs.retrofit2) + implementation(libs.converter.gson) + + // Hilt + implementation(libs.hilt.android) + kapt(libs.hilt.android.compiler) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // ViewModel & LifeCycle + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + // UI 확장 + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) +} diff --git a/app/src/androidTest/java/kr/ac/anu/mumu/ExampleInstrumentedTest.kt b/app/src/androidTest/java/kr/ac/anu/mumu/ExampleInstrumentedTest.kt index 6ba8b6f..2857e90 100644 --- a/app/src/androidTest/java/kr/ac/anu/mumu/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/kr/ac/anu/mumu/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package kr.ac.anu.mumu -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("kr.ac.anu.mumu", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b224e33..f0aaafd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + @@ -21,6 +25,10 @@ + + \ No newline at end of file diff --git a/app/src/main/java/kr/ac/anu/mumu/MumuApplication.kt b/app/src/main/java/kr/ac/anu/mumu/MumuApplication.kt new file mode 100644 index 0000000..5eceb62 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/MumuApplication.kt @@ -0,0 +1,7 @@ +package kr.ac.anu.mumu + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MumuApplication : Application() diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/data/datasource/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt new file mode 100644 index 0000000..8c3d418 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/AuthService.kt @@ -0,0 +1,12 @@ +package kr.ac.anu.mumu.data.datasource + +import kr.ac.anu.mumu.data.model.LoginRequest +import kr.ac.anu.mumu.data.model.LoginResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthService { + @POST("/api/users/login") + suspend fun login(@Body request: LoginRequest): Response +} diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt new file mode 100644 index 0000000..01e8b01 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/AuthDtos.kt @@ -0,0 +1,28 @@ +package kr.ac.anu.mumu.data.model + +import com.google.gson.annotations.SerializedName + +data class LoginRequest( + @SerializedName("id") + val id: String, + @SerializedName("password") + val pw: String +) + +data class LoginResponse( + val success: Boolean, + val message: String, + val token: String, + val user: UserDto +) + +data class UserDto( + @SerializedName("user_id") + val userId: Int, + val id: String, + val name: String, + val phone: String?, + @SerializedName("profile_image") + val profileImage: String?, + val role: String +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/data/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt new file mode 100644 index 0000000..27749cc --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/LoginRepositoryImpl.kt @@ -0,0 +1,38 @@ +package kr.ac.anu.mumu.data.repository + +import kr.ac.anu.mumu.data.datasource.AuthService +import kr.ac.anu.mumu.data.model.LoginRequest +import kr.ac.anu.mumu.domain.model.User +import kr.ac.anu.mumu.domain.repository.LoginRepository +import javax.inject.Inject + +class LoginRepositoryImpl @Inject constructor( + private val authService: AuthService +) : LoginRepository { + + override suspend fun login(id: String, pw: String): Result { + return try { + val response = authService.login(LoginRequest(id, pw)) + + if (response.isSuccessful && response.body() != null) { + val result = response.body()!! + + if (result.success) { + val domainUser = User( + name = result.user.name, + token = result.token, + profileImage = result.user.profileImage, + role = result.user.role + ) + Result.success(domainUser) + } else { + Result.failure(Exception(result.message)) + } + } else { + Result.failure(Exception("통신 오류 : ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/di/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/di/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt new file mode 100644 index 0000000..f0cdb98 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt @@ -0,0 +1,29 @@ +package kr.ac.anu.mumu.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kr.ac.anu.mumu.data.datasource.AuthService +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://keagan-slipperier-bewilderedly.ngrok-free.dev") // 서버 주소 + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideAuthService(retrofit: Retrofit): AuthService { + return retrofit.create(AuthService::class.java) + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt new file mode 100644 index 0000000..3e12006 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt @@ -0,0 +1,19 @@ +package kr.ac.anu.mumu.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kr.ac.anu.mumu.data.repository.LoginRepositoryImpl +import kr.ac.anu.mumu.domain.repository.LoginRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindLoginRepository( + loginRepositoryImpl: LoginRepositoryImpl + ): LoginRepository +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/domain/model/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt new file mode 100644 index 0000000..8056a13 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/User.kt @@ -0,0 +1,8 @@ +package kr.ac.anu.mumu.domain.model + +data class User( + val name: String, + val token: String, + val profileImage: String?, + val role: String +) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/domain/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/LoginRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/LoginRepository.kt new file mode 100644 index 0000000..de31e19 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/LoginRepository.kt @@ -0,0 +1,7 @@ +package kr.ac.anu.mumu.domain.repository + +import kr.ac.anu.mumu.domain.model.User + +interface LoginRepository { + suspend fun login(id: String, pw: String): Result +} diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/usecase/.gitkeep b/app/src/main/java/kr/ac/anu/mumu/domain/usecase/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/usecase/LoginUseCase.kt b/app/src/main/java/kr/ac/anu/mumu/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..051a3d6 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/domain/usecase/LoginUseCase.kt @@ -0,0 +1,13 @@ +package kr.ac.anu.mumu.domain.usecase + +import kr.ac.anu.mumu.domain.model.User +import kr.ac.anu.mumu.domain.repository.LoginRepository +import javax.inject.Inject + +class LoginUseCase @Inject constructor( + private val repository: LoginRepository +) { + suspend operator fun invoke(id: String, pw: String): Result { + return repository.login(id, pw) + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt new file mode 100644 index 0000000..5856b43 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginActivity.kt @@ -0,0 +1,127 @@ +package kr.ac.anu.mumu.presentation.login + +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan +import android.util.Log +import android.view.View +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.ActivityLoginBinding +import kr.ac.anu.mumu.presentation.main.MainActivity + +@AndroidEntryPoint +class LoginActivity : AppCompatActivity() { + + private lateinit var binding: ActivityLoginBinding + private val viewModel: LoginViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + viewSet() + + initInputListener() + initObserver() + } + + private fun viewSet() { + val originText = binding.tvTitle.text.toString() + val spannable = SpannableStringBuilder(originText) + + val targetWord = "Mumu" + val start = originText.indexOf(targetWord) + val end = start + targetWord.length + + if (start != -1) { + val color = ContextCompat.getColor(this, R.color.mumumint_300) + + spannable.setSpan( + ForegroundColorSpan(color), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + binding.tvTitle.text = spannable + } + + private fun initInputListener() { + val textWatcher = object : TextWatcher { + override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} + override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} + + override fun afterTextChanged(p0: Editable?) { + val id = binding.etId.text.toString() + val pw = binding.etPw.text.toString() + + binding.btnLogin.isEnabled = id.isNotEmpty() && pw.isNotEmpty() + + binding.tvError.visibility = View.GONE + } + } + + binding.etId.addTextChangedListener(textWatcher) + binding.etPw.addTextChangedListener(textWatcher) + + // 버튼 클릭 시 로그인 요청 + binding.btnLogin.setOnClickListener { + val id = binding.etId.text.toString() + val pw = binding.etPw.text.toString() + + viewModel.login(id, pw) + } + } + + // 로그인 결과 -> UI 업데이트 + private fun initObserver() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + when (state) { + is LoginUiState.Loading -> { + // 로딩 상태 처리 + binding.btnLogin.isEnabled = false + } + is LoginUiState.Success -> { + // 로그인 성공 처리 + val intent = Intent(this@LoginActivity, MainActivity::class.java) + startActivity(intent) + finish() + } + is LoginUiState.Error -> { + // 로그인 실패 처리 + binding.tvError.visibility = View.VISIBLE + Log.e("emumu : ", state.message) + binding.btnLogin.isEnabled = true + } + else -> {} + } + } + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginViewModel.kt new file mode 100644 index 0000000..6bc6345 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/login/LoginViewModel.kt @@ -0,0 +1,43 @@ +package kr.ac.anu.mumu.presentation.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.usecase.LoginUseCase +import javax.inject.Inject + +// 화면 상태 +sealed class LoginUiState { + object Idle : LoginUiState() // 대기 + object Loading : LoginUiState() // 로딩 + object Success : LoginUiState() // 로그인 성공 + data class Error(val message: String) : LoginUiState() // 실패 +} + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginUseCase: LoginUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + // 로그인 함수 + fun login(id: String, pw: String) { + viewModelScope.launch { + _uiState.value = LoginUiState.Loading + + loginUseCase(id, pw) + .onSuccess { + _uiState.value = LoginUiState.Success + } + .onFailure { error -> + _uiState.value = LoginUiState.Error(error.message ?: "로그인 실패") + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt index 93bdd86..7901e6c 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt @@ -18,4 +18,4 @@ class MainActivity : AppCompatActivity() { insets } } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/btn_disabled.xml b/app/src/main/res/drawable/btn_disabled.xml new file mode 100644 index 0000000..375755b --- /dev/null +++ b/app/src/main/res/drawable/btn_disabled.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_enabled.xml b/app/src/main/res/drawable/btn_enabled.xml new file mode 100644 index 0000000..3fce4ed --- /dev/null +++ b/app/src/main/res/drawable/btn_enabled.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_join_background.xml b/app/src/main/res/drawable/btn_join_background.xml new file mode 100644 index 0000000..ab4e216 --- /dev/null +++ b/app/src/main/res/drawable/btn_join_background.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_selector.xml b/app/src/main/res/drawable/btn_selector.xml new file mode 100644 index 0000000..c876632 --- /dev/null +++ b/app/src/main/res/drawable/btn_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gray_edit_box.xml b/app/src/main/res/drawable/gray_edit_box.xml new file mode 100644 index 0000000..f7c1aec --- /dev/null +++ b/app/src/main/res/drawable/gray_edit_box.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..0c52a7a --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/kr/ac/anu/mumu/ExampleUnitTest.kt b/app/src/test/java/kr/ac/anu/mumu/ExampleUnitTest.kt index 11aca07..fce10fb 100644 --- a/app/src/test/java/kr/ac/anu/mumu/ExampleUnitTest.kt +++ b/app/src/test/java/kr/ac/anu/mumu/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package kr.ac.anu.mumu +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 922f551..08c5569 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.hilt.android) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b419512..f58600b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,41 @@ [versions] +activityKtx = "1.12.3" agp = "8.10.1" +fragmentKtx = "1.8.9" +hiltAndroid = "2.51.1" kotlin = "2.0.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" appcompat = "1.7.1" +kotlinxCoroutinesAndroid = "1.9.0" +lifecycleViewmodelKtx = "2.10.0" material = "1.13.0" activity = "1.12.1" constraintlayout = "2.2.1" +retrofit2 = "2.9.0" [libraries] +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit2" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +retrofit2 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit2" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }