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">
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml
index 95626f9..de02694 100644
--- a/android/app/src/main/res/values-night/themes.xml
+++ b/android/app/src/main/res/values-night/themes.xml
@@ -27,6 +27,8 @@
- @color/md_theme_dark_surfaceVariant
- @color/md_theme_dark_onSurfaceVariant
- @color/md_theme_dark_inversePrimary
+ - @color/md_theme_dark_surface
+ - false
+