diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index eecd9ed..3dbbbf2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -70,4 +70,8 @@ dependencies {
// UI 확장
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx)
+
+ // 네비게이션
+ implementation(libs.androidx.navigation.fragment.ktx)
+ implementation(libs.androidx.navigation.ui.ktx)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f0aaafd..111c250 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
+
@@ -30,5 +33,4 @@
android:exported="false" />
-
\ No newline at end of file
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/JoinService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/JoinService.kt
new file mode 100644
index 0000000..9dd4e87
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/JoinService.kt
@@ -0,0 +1,14 @@
+package kr.ac.anu.mumu.data.datasource
+
+import kr.ac.anu.mumu.data.model.JoinRequest
+import kr.ac.anu.mumu.data.model.JoinResponse
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface JoinService {
+ @POST("/api/users/register")
+ suspend fun registerUser(
+ @Body request: JoinRequest
+ ): Response
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt
new file mode 100644
index 0000000..314dbd8
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/model/JoinDto.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class JoinRequest(
+ val id: String,
+ val password: String,
+ val name: String,
+ val phone: String,
+ val address: String,
+ @SerializedName("detail_address") val detailAddress: String,
+ @SerializedName("postal_code") val postalCode: String,
+ @SerializedName("terms_agreed") val termsAgreed: Boolean,
+ @SerializedName("privacy_agreed") val privacyAgreed: Boolean,
+ @SerializedName("marketing_agreed") val marketingAgreed: Boolean
+)
+
+data class JoinResponse(
+ val success: Boolean,
+ val message: String,
+ val token: String?,
+ val user: UserDto?
+) {
+ data class UserDto(
+ val userId: Int,
+ val id: String,
+ val name: String,
+ val phone: String
+ )
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt
new file mode 100644
index 0000000..4313d7d
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/JoinRepositoryImpl.kt
@@ -0,0 +1,30 @@
+package kr.ac.anu.mumu.data.repository
+
+import kr.ac.anu.mumu.data.datasource.JoinService
+import kr.ac.anu.mumu.data.model.JoinRequest
+import kr.ac.anu.mumu.domain.repository.JoinRepository
+import org.json.JSONObject
+import javax.inject.Inject
+
+class JoinRepositoryImpl @Inject constructor(
+ private val joinService: JoinService
+) : JoinRepository {
+ override suspend fun register(request: JoinRequest): Result {
+ return try {
+ val response = joinService.registerUser(request)
+ if (response.isSuccessful) {
+ Result.success(Unit)
+ } else {
+ val errorString = response.errorBody()?.string()
+ val errorMessage = try {
+ JSONObject(errorString ?: "").getString("error")
+ } catch (e: Exception) {
+ "회원가입에 실패했습니다."
+ }
+ Result.failure(Exception(errorMessage))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
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
index f0cdb98..8c979fb 100644
--- a/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/di/NetworkModule.kt
@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kr.ac.anu.mumu.data.datasource.AuthService
+import kr.ac.anu.mumu.data.datasource.JoinService
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
@@ -26,4 +27,10 @@ object NetworkModule {
fun provideAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
}
+
+ @Provides
+ @Singleton
+ fun provideJoinService(retrofit: Retrofit): JoinService {
+ return retrofit.create(JoinService::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
index 3e12006..8591104 100644
--- a/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt
+++ b/app/src/main/java/kr/ac/anu/mumu/di/RepositoryModule.kt
@@ -4,7 +4,9 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import kr.ac.anu.mumu.data.repository.JoinRepositoryImpl
import kr.ac.anu.mumu.data.repository.LoginRepositoryImpl
+import kr.ac.anu.mumu.domain.repository.JoinRepository
import kr.ac.anu.mumu.domain.repository.LoginRepository
import javax.inject.Singleton
@@ -16,4 +18,10 @@ abstract class RepositoryModule {
abstract fun bindLoginRepository(
loginRepositoryImpl: LoginRepositoryImpl
): LoginRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindJoinRepository(
+ joinRepositoryImpl: JoinRepositoryImpl
+ ): JoinRepository
}
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/JoinRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/JoinRepository.kt
new file mode 100644
index 0000000..967bcc4
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/JoinRepository.kt
@@ -0,0 +1,7 @@
+package kr.ac.anu.mumu.domain.repository
+
+import kr.ac.anu.mumu.data.model.JoinRequest
+
+interface JoinRepository {
+ suspend fun register(request: JoinRequest): Result
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/usecase/JoinUseCase.kt b/app/src/main/java/kr/ac/anu/mumu/domain/usecase/JoinUseCase.kt
new file mode 100644
index 0000000..126eabc
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/domain/usecase/JoinUseCase.kt
@@ -0,0 +1,13 @@
+package kr.ac.anu.mumu.domain.usecase
+
+import kr.ac.anu.mumu.data.model.JoinRequest
+import kr.ac.anu.mumu.domain.repository.JoinRepository
+import javax.inject.Inject
+
+class JoinUseCase @Inject constructor(
+ private val repository: JoinRepository
+) {
+ suspend operator fun invoke(request: JoinRequest): Result {
+ return repository.register(request)
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt
new file mode 100644
index 0000000..53d45db
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AccountFragment.kt
@@ -0,0 +1,107 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.transition.TransitionManager
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAccountBinding
+
+class AccountFragment : Fragment() {
+
+ private var _binding: FragmentAccountBinding? = null
+ private val binding get() = _binding!!
+
+ // Activity와 공유하는 ViewModel
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAccountBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // 텍스트 색상 변경
+ viewSet()
+
+ // 입력값 리스너
+ binding.etId.addTextChangedListener {
+ viewModel.inputId.value = it.toString()
+ viewModel.checkButtonEnabled()
+ viewModel.isIdErrorVisible.value = false
+ }
+ binding.etPw.addTextChangedListener {
+ viewModel.inputPw.value = it.toString()
+ viewModel.checkButtonEnabled()
+ viewModel.isPwErrorVisible.value = false
+ }
+ binding.etPwCheck.addTextChangedListener {
+ viewModel.inputPwCheck.value = it.toString()
+ viewModel.checkButtonEnabled()
+ viewModel.isPwCheckErrorVisible.value = false
+ }
+
+ // 단계별 UI 오픈
+ viewModel.accountStep.observe(viewLifecycleOwner) { step ->
+ TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
+ binding.groupStepPw.visibility = if (step >= 1) View.VISIBLE else View.GONE
+
+ binding.groupStepPwCheck.visibility = if (step >= 2) View.VISIBLE else View.GONE
+
+ viewModel.checkButtonEnabled()
+ }
+
+ // 에러 메시지 UI 반영
+ viewModel.isIdErrorVisible.observe(viewLifecycleOwner) { isVisible ->
+ binding.tvIdError.visibility = if (isVisible) View.VISIBLE else View.GONE
+ }
+ viewModel.isPwErrorVisible.observe(viewLifecycleOwner) { isVisible ->
+ binding.tvPwError.visibility = if (isVisible) View.VISIBLE else View.GONE
+ }
+ viewModel.isPwCheckErrorVisible.observe(viewLifecycleOwner) { isVisible ->
+ binding.tvPwCheckError.visibility = if (isVisible) View.VISIBLE else View.GONE
+ }
+ }
+
+ 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) {
+ context?.let { ctx ->
+ val color = ContextCompat.getColor(ctx, R.color.mumumint_300)
+
+ spannable.setSpan(
+ ForegroundColorSpan(color),
+ start,
+ end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ }
+ binding.tvTitle.text = spannable
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt
new file mode 100644
index 0000000..afb8663
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/AddressFragment.kt
@@ -0,0 +1,85 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentAddressBinding
+
+class AddressFragment : Fragment() {
+ private var _binding: FragmentAddressBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentAddressBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewSet()
+
+ viewModel.checkAddressStep()
+
+ viewModel.inputPostalCode.observe(viewLifecycleOwner) { postal ->
+ binding.tvPostalCode.text = postal
+ }
+
+ viewModel.inputAddress.observe(viewLifecycleOwner) { address ->
+ binding.tvAddress.text = address
+ }
+
+ binding.btnSearch.setOnClickListener {
+ // TODO 카카오 API
+ viewModel.inputPostalCode.value = "36729"
+ viewModel.inputAddress.value = "경상북도 안동시 경동로 1375"
+ }
+
+ binding.etAddress.addTextChangedListener { text ->
+ viewModel.inputDetailAddress.value = text.toString()
+
+ viewModel.checkAddressStep()
+ }
+ }
+
+ 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(requireContext(), R.color.mumumint_300)
+
+ spannable.setSpan(
+ ForegroundColorSpan(color),
+ start,
+ end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+
+ binding.tvTitle.text = spannable
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt
new file mode 100644
index 0000000..7cd2df2
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/FinishFragment.kt
@@ -0,0 +1,61 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentFinishBinding
+
+class FinishFragment : Fragment() {
+ private var _binding: FragmentFinishBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentFinishBinding.inflate(layoutInflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewSet()
+ }
+
+ 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) {
+ // requireContext()를 사용하여 안전하게 색상 가져오기
+ val color = ContextCompat.getColor(requireContext(), R.color.mumumint_300)
+
+ spannable.setSpan(
+ ForegroundColorSpan(color),
+ start,
+ end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+
+ binding.tvTitle.text = spannable
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinActivity.kt
new file mode 100644
index 0000000..4f88653
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinActivity.kt
@@ -0,0 +1,109 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import dagger.hilt.android.AndroidEntryPoint
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.ActivityJoinBinding
+
+@AndroidEntryPoint
+class JoinActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityJoinBinding
+ private val viewModel: JoinViewModel by viewModels()
+ private lateinit var navController: NavController
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ binding = ActivityJoinBinding.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
+ }
+
+ val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
+ navController = navHostFragment.navController
+
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ when (destination.id) {
+ R.id.nameFragment -> viewModel.setStep(3)
+ R.id.phoneFragment -> viewModel.setStep(4)
+ R.id.addressFragment -> viewModel.setStep(5)
+ R.id.TermsFragment -> viewModel.setStep(6)
+ R.id.FinishFragment -> {
+ viewModel.setStep(7)
+ binding.btnNext.isEnabled = true
+ }
+ }
+ }
+
+ viewModel.isButtonEnabled.observe(this) { isEnabled ->
+ binding.btnNext.isEnabled = isEnabled
+ }
+
+ // 뒤로 가기
+ binding.ibBack.setOnClickListener {
+ val currentDest = navController.currentDestination?.id
+
+ if (currentDest == R.id.accountFragment) {
+ finish()
+ } else {
+ navController.popBackStack()
+ }
+ }
+
+ // 다음 버튼
+ binding.btnNext.setOnClickListener {
+ viewModel.onNextClick()
+ }
+
+ viewModel.accountStep.observe(this) { currentStep ->
+ if (currentStep == 7) {
+ binding.btnNext.text = "완료"
+ } else {
+ binding.btnNext.text = "다음"
+ }
+ }
+
+ viewModel.finishJoinFlow.observe(this) { isFinished ->
+ if (isFinished) {
+ finish()
+ }
+ }
+
+ // 페이지 이동 신호 감지
+ viewModel.moveToNextPage.observe(this) { move ->
+ if (move) {
+ val currentDest = navController.currentDestination?.id
+
+ if (currentDest == R.id.accountFragment) {
+ navController.navigate(R.id.action_accountFragment_to_nameFragment)
+ } else if (currentDest == R.id.nameFragment) {
+ navController.navigate(R.id.action_nameFragment_to_phoneFragment)
+ } else if (currentDest == R.id.phoneFragment) {
+ navController.navigate(R.id.action_phoneFragment_to_addressFragment)
+ } else if (currentDest == R.id.addressFragment) {
+ navController.navigate(R.id.action_addressFragment_to_TermsFragment)
+ } else if (currentDest == R.id.TermsFragment) {
+ navController.navigate(R.id.action_TermsFragment_to_FinishFragment)
+ }
+
+ viewModel.doneNavigation()
+ }
+ }
+
+ viewModel.joinErrorMessage.observe(this) { message ->
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt
new file mode 100644
index 0000000..55d2428
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/JoinViewModel.kt
@@ -0,0 +1,242 @@
+package kr.ac.anu.mumu.presentation.join
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import kr.ac.anu.mumu.data.model.JoinRequest
+import kr.ac.anu.mumu.domain.usecase.JoinUseCase
+import javax.inject.Inject
+
+@HiltViewModel
+class JoinViewModel @Inject constructor(
+ private val joinUseCase: JoinUseCase
+) : ViewModel() {
+
+ // 입력 데이터
+ val inputId = MutableLiveData("")
+ val inputPw = MutableLiveData("")
+ val inputPwCheck = MutableLiveData("")
+ val inputName = MutableLiveData("")
+ val inputPhoneNum = MutableLiveData("")
+ val inputCheckNum = MutableLiveData("")
+ val inputPostalCode = MutableLiveData("")
+ val inputAddress = MutableLiveData("")
+ val inputDetailAddress = MutableLiveData("")
+ val isAllAgreed = MutableLiveData(false)
+ val isTermsAgreed = MutableLiveData(false)
+ val isPrivacyAgreed = MutableLiveData(false)
+ val isMarketingAgreed = MutableLiveData(false)
+
+ // 화면 상태
+ private val _accountStep = MutableLiveData(0)
+ val accountStep: LiveData get() = _accountStep
+
+ // 에러 메시지 표시 여부
+ val isIdErrorVisible = MutableLiveData(false)
+ val isPwErrorVisible = MutableLiveData(false)
+ val isPwCheckErrorVisible = MutableLiveData(false)
+ val isCheckNumErrorVisible = MutableLiveData(false)
+
+ // 페이지 이동 이벤트
+ private val _moveToNextPage = MutableLiveData()
+ val moveToNextPage: LiveData get() = _moveToNextPage
+
+ private val _finishJoinFlow = MutableLiveData()
+ val finishJoinFlow: LiveData get() = _finishJoinFlow
+
+ // 버튼 활성화 여부
+ private val _isButtonEnabled = MutableLiveData(false)
+ val isButtonEnabled: LiveData get() = _isButtonEnabled
+
+ private val _joinErrorMessage = MutableLiveData()
+ val joinErrorMessage: LiveData get() = _joinErrorMessage
+
+ // 정규식
+ private val ID_REGEX = Regex("^[a-zA-Z0-9]{7,12}\$")
+ private val PW_REGEX = Regex("^(?=.*[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).{8,16}\$")
+
+ fun checkButtonEnabled() {
+ val step = _accountStep.value ?: 0
+ val hasInput = when (step) {
+ 0 -> !inputId.value.isNullOrBlank() // ID 칸에 뭐라도 썼니?
+ 1 -> !inputPw.value.isNullOrBlank() // PW 칸에 뭐라도 썼니?
+ 2 -> !inputPwCheck.value.isNullOrBlank() // 확인 칸에 뭐라도 썼니?
+ else -> false
+ }
+ _isButtonEnabled.value = hasInput
+ }
+
+ // 다음 버튼 클릭시 호출
+ fun onNextClick() {
+ when (_accountStep.value) {
+ 0 -> checkIdStep()
+ 1 -> checkPwStep()
+ 2 -> checkPwCheckStep()
+ 3 -> {
+ if (!inputName.value.isNullOrBlank()) _moveToNextPage.value = true
+ }
+ 4 -> onPhoneCheckClick()
+ 5 -> {
+ if (!inputDetailAddress.value.isNullOrBlank()) _moveToNextPage.value = true
+ }
+ 6 -> onTermsNextClick()
+ 7 -> _finishJoinFlow.value = true
+ }
+ }
+
+ private fun checkIdStep() {
+ val id = inputId.value ?: ""
+
+ if (ID_REGEX.matches(id)) {
+ isIdErrorVisible.value = false
+ _accountStep.value = 1
+ checkButtonEnabled()
+ } else {
+ isIdErrorVisible.value = true
+ }
+ }
+
+ private fun checkPwStep() {
+ val pw = inputPw.value ?: ""
+
+ if (PW_REGEX.matches(pw)) {
+ isPwErrorVisible.value = false
+ _accountStep.value = 2
+ checkButtonEnabled()
+ } else {
+ isPwErrorVisible.value = true
+ }
+ }
+
+ private fun checkPwCheckStep() {
+ val pw = inputPw.value ?: ""
+ val pwCheck = inputPwCheck.value ?: ""
+
+ if (pw == pwCheck && pw.isNotBlank()) {
+ isPwCheckErrorVisible.value = false
+ _moveToNextPage.value = true
+ } else {
+ isPwCheckErrorVisible.value = true
+ }
+ }
+
+ // Name Code
+ fun checkNameStep() {
+ val hasName = !inputName.value.isNullOrBlank()
+ _isButtonEnabled.value = hasName
+ }
+
+ // Phone Code
+ // 인증번호 visiable 여부 결정하는 LiveData
+ private val _isVerificationVisible = MutableLiveData(false)
+ val isVerificationVisible: LiveData get() = _isVerificationVisible
+
+ fun checkPhoneStep() {
+ val phoneLength = inputPhoneNum.value?.length ?: 0
+ val isPhoneValid = phoneLength == 10 || phoneLength == 11
+
+ _isVerificationVisible.value = isPhoneValid
+
+ checkPhoneButtonEnabled()
+ }
+
+ fun checkPhoneButtonEnabled() {
+ val phoneLength = inputPhoneNum.value?.length ?: 0
+ val isPhoneValid = phoneLength == 10 || phoneLength == 11
+
+ val isCodeValid = !inputCheckNum.value.isNullOrBlank()
+
+ // 전화번호도 맞고 인증번호도 쳤을 때만 최종 버튼 켜기
+ _isButtonEnabled.value = isPhoneValid && isCodeValid
+ }
+
+ fun onPhoneCheckClick() {
+ val phoneLength = inputPhoneNum.value?.length ?: 0
+ val isPhoneValid = phoneLength == 10 || phoneLength == 11
+ val currentCode = inputCheckNum.value ?: ""
+
+ if (isPhoneValid) {
+ if (currentCode == "123456") {
+ isCheckNumErrorVisible.value = false
+ _moveToNextPage.value = true
+ } else {
+ isCheckNumErrorVisible.value = true
+ }
+ }
+ }
+
+ // Address
+ fun checkAddressStep() {
+ // TODO 우편번호랑 도로명은 주소 API 사용하면서 작성
+ val hasDetail = !inputDetailAddress.value.isNullOrBlank()
+ _isButtonEnabled.value = hasDetail
+ }
+
+ // Terms
+ fun checkAgreementStep() {
+ val terms = isTermsAgreed.value ?: false
+ val privacy = isPrivacyAgreed.value ?: false
+
+ _isButtonEnabled.value = terms && privacy
+ }
+
+ fun onAllAgreeClicked(isChecked: Boolean) {
+ isAllAgreed.value = isChecked
+ isTermsAgreed.value = isChecked
+ isPrivacyAgreed.value = isChecked
+ isMarketingAgreed.value = isChecked
+
+ checkAgreementStep()
+ }
+
+ fun onSingleAgreeClicked() {
+ val terms = isTermsAgreed.value ?: false
+ val privacy = isPrivacyAgreed.value ?: false
+ val marketing = isMarketingAgreed.value ?: false
+
+ isAllAgreed.value = terms && privacy && marketing
+
+ checkAgreementStep()
+ }
+
+ fun onTermsNextClick() {
+ _isButtonEnabled.value = false
+
+ viewModelScope.launch {
+ val request = JoinRequest(
+ id = inputId.value ?: "",
+ password = inputPw.value ?: "",
+ name = inputName.value ?: "",
+ phone = inputPhoneNum.value ?: "",
+ address = inputAddress.value ?: "",
+ detailAddress = inputDetailAddress.value ?: "",
+ postalCode = inputPostalCode.value ?: "",
+ termsAgreed = isTermsAgreed.value ?: false,
+ privacyAgreed = isPrivacyAgreed.value ?: false,
+ marketingAgreed = isMarketingAgreed.value ?: false
+ )
+
+ val result = joinUseCase(request)
+
+ result.fold(
+ onSuccess = {
+ _moveToNextPage.value = true
+ },
+ onFailure = { error ->
+ _joinErrorMessage.value = error.message ?: "네트워크 오류가 발생했습니다."
+ _isButtonEnabled.value = true
+ }
+ )
+ }
+ }
+
+ // 네비게이션 완료 후 이벤트 초기화
+ fun doneNavigation() { _moveToNextPage.value = false }
+
+ fun setStep(step: Int) {
+ _accountStep.value = step
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/NameFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/NameFragment.kt
new file mode 100644
index 0000000..a05eaba
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/NameFragment.kt
@@ -0,0 +1,41 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.databinding.FragmentNameBinding
+
+class NameFragment : Fragment() {
+ private var _binding: FragmentNameBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentNameBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel.checkNameStep()
+
+ binding.etName.addTextChangedListener { text ->
+ viewModel.inputName.value = text.toString()
+ viewModel.checkNameStep()
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/PhoneFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/PhoneFragment.kt
new file mode 100644
index 0000000..a3bc11c
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/PhoneFragment.kt
@@ -0,0 +1,104 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.R
+import kr.ac.anu.mumu.databinding.FragmentPhoneBinding
+
+class PhoneFragment : Fragment() {
+ private var _binding: FragmentPhoneBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentPhoneBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel.checkPhoneStep()
+
+ viewModel.isVerificationVisible.observe(viewLifecycleOwner) { isVisible ->
+ android.transition.TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
+ binding.groupStepCheckNum.visibility = if (isVisible) View.VISIBLE else View.GONE
+ }
+
+ binding.etPhoneNum.addTextChangedListener(object : TextWatcher {
+
+ private var isFormatting = false // 무한 루프 방지용 플래그
+
+ 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(s: Editable?) {
+ if (isFormatting || s == null) return
+
+ isFormatting = true
+
+ // 입력 값중 숫자만 추출
+ val phoneNumber = s.toString().replace(Regex("[^0-9]"), "")
+
+ // viewModel에 숫자만 저장
+ viewModel.inputPhoneNum.value = phoneNumber
+
+ // 화면에 보여줄 하이픈 포맷 만들기
+ val formattedNumber = formatPhoneNumber(phoneNumber)
+ s.replace(0, s.length, formattedNumber)
+
+ viewModel.checkPhoneStep()
+
+ isFormatting = false
+ }
+ })
+
+ viewModel.isCheckNumErrorVisible.observe(viewLifecycleOwner) { isError ->
+ if (isError) {
+ binding.etCheckNum.setBackgroundResource(R.drawable.bg_edit_error_underline)
+ } else {
+ binding.etCheckNum.setBackgroundResource(R.drawable.bg_edit_underline)
+ }
+ }
+
+ binding.etCheckNum.addTextChangedListener(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(s: Editable?) {
+ viewModel.inputCheckNum.value = s?.toString() ?: ""
+
+ viewModel.isCheckNumErrorVisible.value = false
+
+ viewModel.checkPhoneButtonEnabled()
+ }
+ })
+ }
+
+ private fun formatPhoneNumber(raw: String): String {
+ return when {
+ raw.length <= 3 -> raw
+ raw.length <= 7 -> "${raw.substring(0, 3)}-${raw.substring(3)}"
+ else -> {
+ val limit = if (raw.length > 11) raw.substring(0, 11) else raw
+ "${limit.substring(0, 3)}-${limit.substring(3, 7)}-${limit.substring(7)}"
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/join/TermsFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/join/TermsFragment.kt
new file mode 100644
index 0000000..5a1c540
--- /dev/null
+++ b/app/src/main/java/kr/ac/anu/mumu/presentation/join/TermsFragment.kt
@@ -0,0 +1,75 @@
+package kr.ac.anu.mumu.presentation.join
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import kr.ac.anu.mumu.databinding.FragmentTermsBinding
+
+class TermsFragment : Fragment() {
+ private var _binding: FragmentTermsBinding? = null
+ private val binding get() = _binding!!
+ private val viewModel: JoinViewModel by activityViewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = FragmentTermsBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel.checkAgreementStep()
+
+ // 상태에 따른 체크박스 UI 변경
+ viewModel.isAllAgreed.observe(viewLifecycleOwner) { isChecked ->
+ binding.cbAll.isChecked = isChecked
+ }
+ viewModel.isTermsAgreed.observe(viewLifecycleOwner) { isChecked ->
+ binding.cbTerms.isChecked = isChecked
+ }
+ viewModel.isPrivacyAgreed.observe(viewLifecycleOwner) { isChecked ->
+ binding.cbPrivacy.isChecked = isChecked
+ }
+
+ viewModel.isMarketingAgreed.observe(viewLifecycleOwner) { isChecked ->
+ binding.cbMarketing.isChecked = isChecked
+ }
+
+ // 뷰 클릭시 viewModel에 상태 전달
+ binding.cbAll.setOnClickListener {
+ val isChecked = binding.cbAll.isChecked
+ viewModel.onAllAgreeClicked(isChecked)
+ }
+
+ binding.cbTerms.setOnClickListener {
+ viewModel.isTermsAgreed.value = binding.cbTerms.isChecked
+ viewModel.onSingleAgreeClicked()
+ }
+
+ binding.cbPrivacy.setOnClickListener {
+ viewModel.isPrivacyAgreed.value = binding.cbPrivacy.isChecked
+ viewModel.onSingleAgreeClicked()
+ }
+
+ binding.cbMarketing.setOnClickListener {
+ viewModel.isMarketingAgreed.value = binding.cbMarketing.isChecked
+ viewModel.onSingleAgreeClicked()
+ }
+
+ binding.tvTermsDetail.setOnClickListener {
+ // 약관 내용 보여주기
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
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
index 5856b43..5d1dd44 100644
--- 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
@@ -22,6 +22,7 @@ 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.join.JoinActivity
import kr.ac.anu.mumu.presentation.main.MainActivity
@AndroidEntryPoint
@@ -94,6 +95,10 @@ class LoginActivity : AppCompatActivity() {
viewModel.login(id, pw)
}
+ binding.btnJoin.setOnClickListener {
+ val intent = Intent(this, JoinActivity::class.java)
+ startActivity(intent)
+ }
}
// 로그인 결과 -> UI 업데이트
diff --git a/app/src/main/res/drawable/back.xml b/app/src/main/res/drawable/back.xml
new file mode 100644
index 0000000..3544a7b
--- /dev/null
+++ b/app/src/main/res/drawable/back.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/bg_edit_error_underline.xml b/app/src/main/res/drawable/bg_edit_error_underline.xml
new file mode 100644
index 0000000..de75a35
--- /dev/null
+++ b/app/src/main/res/drawable/bg_edit_error_underline.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_edit_underline.xml b/app/src/main/res/drawable/bg_edit_underline.xml
new file mode 100644
index 0000000..3c179c5
--- /dev/null
+++ b/app/src/main/res/drawable/bg_edit_underline.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/mumu_logo.png b/app/src/main/res/drawable/mumu_logo.png
new file mode 100644
index 0000000..bc92a08
Binary files /dev/null and b/app/src/main/res/drawable/mumu_logo.png differ
diff --git a/app/src/main/res/layout/activity_join.xml b/app/src/main/res/layout/activity_join.xml
new file mode 100644
index 0000000..2439810
--- /dev/null
+++ b/app/src/main/res/layout/activity_join.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
index 0c52a7a..779ee7a 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -29,6 +29,9 @@
android:layout_marginTop="30dp"
android:background="@drawable/gray_edit_box"
android:hint="아이디"
+ android:imeOptions="actionNext"
+ android:inputType="text"
+ android:maxLines="1"
android:paddingHorizontal="16dp"
android:textColor="@color/mumugray_350"
android:textColorHint="@color/mumugray_150"
@@ -45,7 +48,9 @@
android:layout_marginTop="8dp"
android:background="@drawable/gray_edit_box"
android:hint="비밀번호"
+ android:imeOptions="actionDone"
android:inputType="textPassword"
+ android:maxLines="1"
android:paddingHorizontal="16dp"
android:textColor="@color/mumublack"
android:textColorHint="@color/mumugray_150"
diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml
new file mode 100644
index 0000000..28d3a0a
--- /dev/null
+++ b/app/src/main/res/layout/fragment_account.xml
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_address.xml b/app/src/main/res/layout/fragment_address.xml
new file mode 100644
index 0000000..d7e4b61
--- /dev/null
+++ b/app/src/main/res/layout/fragment_address.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_finish.xml b/app/src/main/res/layout/fragment_finish.xml
new file mode 100644
index 0000000..22a714b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_finish.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_name.xml b/app/src/main/res/layout/fragment_name.xml
new file mode 100644
index 0000000..6417d6d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_name.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_phone.xml b/app/src/main/res/layout/fragment_phone.xml
new file mode 100644
index 0000000..38f78c7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_phone.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_terms.xml b/app/src/main/res/layout/fragment_terms.xml
new file mode 100644
index 0000000..497a385
--- /dev/null
+++ b/app/src/main/res/layout/fragment_terms.xml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph_join.xml b/app/src/main/res/navigation/nav_graph_join.xml
new file mode 100644
index 0000000..8fd4d8a
--- /dev/null
+++ b/app/src/main/res/navigation/nav_graph_join.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f58600b..6ec3a43 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ lifecycleViewmodelKtx = "2.10.0"
material = "1.13.0"
activity = "1.12.1"
constraintlayout = "2.2.1"
+navigationFragmentKtx = "2.9.7"
retrofit2 = "2.9.0"
[libraries]
@@ -22,6 +23,8 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
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" }
+androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
+androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigationFragmentKtx" }
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" }