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" }