diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2c9e0df..8817d8c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ android:roundIcon="@mipmap/ic_priceguard_round" android:supportsRtl="true" android:theme="@style/Theme.PriceGuard" - tools:targetApi="31"> + tools:targetApi="34"> diff --git a/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt b/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt index 901625b..da841e6 100644 --- a/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt @@ -1,11 +1,184 @@ package app.priceguard.ui.signup -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.widget.NestedScrollView +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import app.priceguard.R +import app.priceguard.databinding.ActivitySignupBinding +import app.priceguard.ui.signup.SignupViewModel.SignupEvent +import app.priceguard.ui.signup.SignupViewModel.SignupUIState +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.Behavior.DragCallback +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.CircularProgressIndicatorSpec +import com.google.android.material.progressindicator.IndeterminateDrawable +import kotlinx.coroutines.launch class SignupActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySignupBinding + private val signupViewModel: SignupViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_signup) + binding.vm = signupViewModel + binding.lifecycleOwner = this + + setNavigationButton() + disableAppBarScroll() + observeState() + } + + private fun disableAppBarScroll() { + val clLayoutParams = binding.ablSignupTopbar.layoutParams as CoordinatorLayout.LayoutParams + val scrollView: NestedScrollView = binding.nsvSignupContent + val viewTreeObserver = scrollView.viewTreeObserver + val disabledAblBehavior = getAblBehavior(false) + val enabledAblBehavior = getAblBehavior(true) + + viewTreeObserver.addOnGlobalLayoutListener { + if (scrollView.measuredHeight - scrollView.getChildAt(0).height >= 0) { + clLayoutParams.behavior = disabledAblBehavior + } else { + clLayoutParams.behavior = enabledAblBehavior + } + } + } + + private fun getAblBehavior(canDrag: Boolean): AppBarLayout.Behavior { + val ablBehavior = AppBarLayout.Behavior() + ablBehavior.setDragCallback(object : DragCallback() { + override fun canDrag(appBarLayout: AppBarLayout): Boolean { + return canDrag + } + }) + return ablBehavior + } + + private fun handleSignupEvent(event: SignupEvent) { + val spec = + CircularProgressIndicatorSpec( + this, + null, + 0, + R.style.Theme_PriceGuard_CircularProgressIndicator + ) + + val progressIndicatorDrawable = + IndeterminateDrawable.createCircularDrawable(this, spec).apply { + setVisible(true, true) + } + + when (event) { + is SignupEvent.SignupStart -> { + (binding.btnSignupSignup as MaterialButton).icon = progressIndicatorDrawable + } + + is SignupEvent.SignupFinish -> { + (binding.btnSignupSignup as MaterialButton).icon = null + } + + is SignupEvent.SignupError -> {} + } + } + + private fun setNavigationButton() { + binding.mtSignupTopbar.setNavigationOnClickListener { + finish() + } + } + + private fun observeState() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + signupViewModel.state.collect { state -> + updateNameTextFieldUI(state) + updateEmailTextFieldUI(state) + updatePasswordTextFieldUI(state) + updateRetypePasswordTextFieldUI(state) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + signupViewModel.eventFlow.collect { event -> + handleSignupEvent(event) + } + } + } + } + + private fun updateNameTextFieldUI(state: SignupUIState) { + when (state.isNameError) { + true -> { + binding.tilSignupName.error = getString(R.string.name_required) + } + + else -> { + binding.tilSignupName.error = null + } + } + } + + private fun updateEmailTextFieldUI(state: SignupUIState) { + when (state.isEmailError) { + null -> { + binding.tilSignupEmail.error = null + binding.tilSignupEmail.helperText = " " + } + + true -> { + binding.tilSignupEmail.error = getString(R.string.invalid_email) + } + + false -> { + binding.tilSignupEmail.error = null + binding.tilSignupEmail.helperText = getString(R.string.valid_email) + } + } + } + + private fun updatePasswordTextFieldUI(state: SignupUIState) { + when (state.isPasswordError) { + null -> { + binding.tilSignupPassword.error = null + binding.tilSignupPassword.helperText = " " + } + + true -> { + binding.tilSignupPassword.error = getString(R.string.invalid_password) + } + + false -> { + binding.tilSignupPassword.error = null + binding.tilSignupPassword.helperText = getString(R.string.valid_password) + } + } + } + + private fun updateRetypePasswordTextFieldUI(state: SignupUIState) { + when (state.isRetypePasswordError) { + null -> { + binding.tilSignupRetypePassword.error = null + binding.tilSignupRetypePassword.helperText = " " + } + + true -> { + binding.tilSignupRetypePassword.error = getString(R.string.password_mismatch) + } + + false -> { + binding.tilSignupRetypePassword.error = null + binding.tilSignupRetypePassword.helperText = getString(R.string.password_match) + } + } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt b/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt new file mode 100644 index 0000000..443730d --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt @@ -0,0 +1,190 @@ +package app.priceguard.ui.signup + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class SignupViewModel : ViewModel() { + + data class SignupUIState( + val name: String = "", + val email: String = "", + val password: String = "", + val retypePassword: String = "", + val isSignupReady: Boolean = false, + val isNameError: Boolean? = null, + val isEmailError: Boolean? = null, + val isPasswordError: Boolean? = null, + val isRetypePasswordError: Boolean? = null, + val isSignupStarted: Boolean = false + ) + + private val emailPattern = + """^[\w.+-]+@((?!-)[A-Za-z0-9-]{1,63}(? = MutableStateFlow(SignupUIState()) + val state: StateFlow = _state.asStateFlow() + + private val _eventFlow: MutableSharedFlow = MutableSharedFlow(replay = 0) + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + fun signup() { + viewModelScope.launch { + if (_state.value.isSignupStarted) { + Log.d("Signup", "Signup already requested. Skipping") + return@launch + } + + sendEvent(SignupEvent.SignupStart) + updateSignupStarted(true) + Log.d("ViewModel", "Event Start Sent") + delay(3000L) + // TODO: 제거하고 Signup 네트워크 로직 넣기 + // TODO: 회원가입 성공시 로그인 정보 저장하기 + // TODO: 회원가입 실패시 메세지 표시하기 + sendEvent(SignupEvent.SignupFinish) + updateSignupStarted(false) + Log.d("ViewModel", "Event Finish Sent") + } + } + + fun updateName(name: String) { + _state.value = _state.value.copy(name = name) + + updateNameError() + updateIsSignupReady() + } + + fun updateEmail(email: String) { + _state.value = _state.value.copy(email = email) + + updateEmailError() + updateIsSignupReady() + } + + fun updatePassword(password: String) { + _state.value = _state.value.copy(password = password) + + updatePasswordError() + updateRetypePasswordError() + updateIsSignupReady() + } + + fun updateRetypePassword(retypePassword: String) { + _state.value = _state.value.copy(retypePassword = retypePassword) + + updateRetypePasswordError() + updateIsSignupReady() + } + + private fun isValidName(): Boolean { + return _state.value.name.isNotBlank() + } + + private fun isValidEmail(): Boolean { + return emailPattern.matchEntire(_state.value.email) != null + } + + private fun isValidPassword(): Boolean { + return passwordPattern.matchEntire(_state.value.password) != null + } + + private fun isValidRetypePassword(): Boolean { + return _state.value.retypePassword.isNotBlank() && _state.value.password == _state.value.retypePassword + } + + private fun sendEvent(event: SignupEvent) { + viewModelScope.launch { + _eventFlow.emit(event) + } + } + + private fun updateIsSignupReady() { + _state.value = + _state.value.copy(isSignupReady = isValidName() && isValidEmail() && isValidPassword() && isValidRetypePassword()) + } + + private fun updateNameError() { + _state.value.let { uiState -> + if (isValidName()) { + _state.value = uiState.copy(isNameError = false) + } else { + _state.value = uiState.copy(isNameError = true) + } + } + } + + private fun updateEmailError() { + _state.value.let { uiState -> + when { + isValidEmail() -> { + _state.value = uiState.copy(isEmailError = false) + } + + uiState.email.isEmpty() -> { + _state.value = uiState.copy(isEmailError = null) + } + + else -> { + _state.value = uiState.copy(isEmailError = true) + } + } + } + } + + private fun updatePasswordError() { + _state.value.let { uiState -> + when { + isValidPassword() -> { + _state.value = uiState.copy(isPasswordError = false) + } + + uiState.password.isEmpty() -> { + _state.value = uiState.copy(isPasswordError = null) + } + + else -> { + _state.value = uiState.copy(isPasswordError = true) + } + } + } + } + + private fun updateRetypePasswordError() { + _state.value.let { uiState -> + when { + isValidRetypePassword() -> { + _state.value = uiState.copy(isRetypePasswordError = false) + } + + uiState.retypePassword.isEmpty() -> { + _state.value = uiState.copy(isRetypePasswordError = null) + } + + else -> { + _state.value = uiState.copy(isRetypePasswordError = true) + } + } + } + } + + private fun updateSignupStarted(started: Boolean) { + _state.value = _state.value.copy(isSignupStarted = started) + } + + sealed class SignupEvent { + object SignupStart : SignupEvent() + object SignupFinish : SignupEvent() + object SignupError : SignupEvent() + } +} diff --git a/android/app/src/main/res/drawable/ic_back.xml b/android/app/src/main/res/drawable/ic_back.xml index 8452791..aeceea9 100644 --- a/android/app/src/main/res/drawable/ic_back.xml +++ b/android/app/src/main/res/drawable/ic_back.xml @@ -1,5 +1,5 @@ diff --git a/android/app/src/main/res/layout/activity_signup.xml b/android/app/src/main/res/layout/activity_signup.xml index 45378aa..516b6d3 100644 --- a/android/app/src/main/res/layout/activity_signup.xml +++ b/android/app/src/main/res/layout/activity_signup.xml @@ -1,131 +1,186 @@ - - - - - - - - - - - - - + - + + + - + - - - - - - - + + + + + + + + + + - - - - - - - - - - - - + app:layout_behavior="@string/appbar_scrolling_view_behavior" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@+id/abl_signup_topbar"> - - -