diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginEvent.kt deleted file mode 100644 index 4afdfdb3..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginEvent.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.login - -import com.nextroom.nextroom.presentation.model.UiText - -sealed interface EmailLoginEvent { - data class EmailLoginFailed(val message: String) : EmailLoginEvent - data class ShowMessage(val message: UiText) : EmailLoginEvent - data object GoToGameScreen : EmailLoginEvent - data object GoogleAuthFailed : EmailLoginEvent - data object GoogleLoginFailed : EmailLoginEvent - data class NeedAdditionalUserInfo(val shopName: String?) : EmailLoginEvent -} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginFragment.kt index 468dad2f..50077397 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginFragment.kt @@ -3,150 +3,105 @@ package com.nextroom.nextroom.presentation.ui.login import android.content.Intent import android.net.Uri import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.view.inputmethod.EditorInfo -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.databinding.FragmentEmailLoginBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate -import com.nextroom.nextroom.presentation.extension.setError -import com.nextroom.nextroom.presentation.extension.setStateListener -import com.nextroom.nextroom.presentation.extension.showKeyboard import com.nextroom.nextroom.presentation.extension.snackbar import com.nextroom.nextroom.presentation.extension.toast +import com.nextroom.nextroom.presentation.ui.login.compose.EmailLoginScreen import com.nextroom.nextroom.presentation.ui.onboarding.LoginFragment.Companion.SIGNUP_REQUEST_KEY import dagger.hilt.android.AndroidEntryPoint -import org.orbitmvi.orbit.viewmodel.observe +import kotlinx.coroutines.launch @AndroidEntryPoint -class EmailLoginFragment : BaseFragment(FragmentEmailLoginBinding::inflate) { - - private val viewModel: EmailLoginViewModel by viewModels() - - private var emailInitialised = false - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.checkEmailSaved() - initViews() - observe() - - viewModel.observe(viewLifecycleOwner, state = ::render) - } - - private fun initViews() = with(binding) { - etEmail.apply { - setStateListener() - doAfterTextChanged { - viewModel.inputCode(it.toString()) +class EmailLoginFragment : ComposeBaseViewModelFragment() { + + override val screenName = "email_login" + override val viewModel: EmailLoginViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + EmailLoginScreen( + state = state, + onEmailChange = viewModel::inputEmail, + onPasswordChange = viewModel::inputPassword, + onEmailSaveCheckedChange = viewModel::onEmailSaveChecked, + onLoginClick = viewModel::complete, + onGoogleLoginClick = viewModel::requestGoogleAuth, + onBackClick = { findNavController().navigateUp() }, + onCustomerServiceClick = ::openCustomerService, + onSignupClick = ::openSignupWebView, + ) } - showKeyboard() } - etPassword.apply { - setStateListener() - doAfterTextChanged { - viewModel.inputPassword(it.toString()) - } - setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - viewModel.complete() - true - } + } - else -> false + override fun initSubscribe() { + viewLifecycleOwner.repeatOnStarted { + launch { + viewModel.uiEvent.collect(::handleEvent) + } + launch { + viewModel.loginState.collect { loggedIn -> + if (loggedIn) moveToThemeSelect() } } } + } - tvEmailLogin.setOnClickListener { viewModel.complete() } - llGoogleLogin.setOnClickListener { viewModel.requestGoogleAuth() } + private fun handleEvent(event: EmailLoginViewModel.UiEvent) { + when (event) { + is EmailLoginViewModel.UiEvent.ShowMessage -> + snackbar(event.message.toString(requireContext())) - cbIdSave.setOnCheckedChangeListener { _, isChecked -> - viewModel.onIdSaveChecked(isChecked) - } + is EmailLoginViewModel.UiEvent.EmailLoginFailed -> snackbar(event.message) - tvCustomerService.setOnClickListener { - try { - getString(R.string.link_official_instagram).let { url -> - Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(url) } - }.also { startActivity(it) } - } catch (e: Exception) { - toast(getString(R.string.error_something)) - } - } + EmailLoginViewModel.UiEvent.GoogleAuthFailed, + EmailLoginViewModel.UiEvent.GoogleLoginFailed -> toast(R.string.error_something) - ivBack.setOnClickListener { findNavController().navigateUp() } - tvCustomerService.setOnClickListener { - try { - Intent(Intent.ACTION_VIEW) - .apply { data = Uri.parse(getString(R.string.link_official_instagram)) } - .also { startActivity(it) } - } catch (e: Exception) { - toast(getString(R.string.error_something)) - } - } - tvSignup.setOnClickListener { - NavGraphDirections.moveToWebViewFragment( - url = getString(R.string.link_signup), - showToolbar = true, - ).also { findNavController().safeNavigate(it) } + is EmailLoginViewModel.UiEvent.NeedAdditionalUserInfo -> moveToSignup() } } - private fun observe() { - viewModel.observe(viewLifecycleOwner, sideEffect = ::handleEvent) - viewLifecycleOwner.repeatOnStarted { - viewModel.loginState.collect { loggedIn -> - if (loggedIn) { - val action = - EmailLoginFragmentDirections.moveToThemeSelectFragment() - findNavController().safeNavigate(action) - clearInputs() + private fun openCustomerService() { + try { + startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.link_official_instagram)) } - } + ) + } catch (e: Exception) { + toast(getString(R.string.error_something)) } } - private fun render(state: EmailLoginState) = with(binding) { - pbLoading.isVisible = state.loading - etEmail.isEnabled = !state.loading - etPassword.isEnabled = !state.loading - if (!emailInitialised) { - emailInitialised = true - etEmail.setText(state.userEmail) - cbIdSave.isChecked = state.idSaveChecked - } - tvEmailLogin.isEnabled = !state.loading - } - - private fun handleEvent(event: EmailLoginEvent) { - when (event) { - is EmailLoginEvent.ShowMessage -> snackbar(event.message.toString(requireContext())) - is EmailLoginEvent.EmailLoginFailed -> { - binding.etEmail.setError() - binding.etPassword.setError() - snackbar(event.message) - } - - EmailLoginEvent.GoToGameScreen -> Unit - EmailLoginEvent.GoogleAuthFailed, - EmailLoginEvent.GoogleLoginFailed -> toast(R.string.error_something) - - is EmailLoginEvent.NeedAdditionalUserInfo -> moveToSignup() - } + private fun openSignupWebView() { + NavGraphDirections.moveToWebViewFragment( + url = getString(R.string.link_signup), + showToolbar = true, + ).also { findNavController().safeNavigate(it) } } - private fun clearInputs() { - binding.etEmail.setText("") - binding.etPassword.setText("") - viewModel.initState() + private fun moveToThemeSelect() { + findNavController().safeNavigate(EmailLoginFragmentDirections.moveToThemeSelectFragment()) } private fun moveToSignup() { diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginState.kt deleted file mode 100644 index 868c765d..00000000 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginState.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nextroom.nextroom.presentation.ui.login - -import com.nextroom.nextroom.presentation.model.InputState - -data class EmailLoginState( - val loading: Boolean = false, - val currentIdInput: String = "", - val idInputState: InputState = InputState.Empty, - val currentPasswordInput: String = "", - val passwordInputState: InputState = InputState.Empty, - val idSaveChecked: Boolean = false, - val userEmail: String = "", -) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginViewModel.kt index d4968951..eaf0037d 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/EmailLoginViewModel.kt @@ -9,150 +9,137 @@ import com.nextroom.nextroom.domain.model.onFinally import com.nextroom.nextroom.domain.model.onSuccess import com.nextroom.nextroom.domain.repository.AdminRepository import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseViewModel -import com.nextroom.nextroom.presentation.model.InputState +import com.nextroom.nextroom.presentation.base.NewBaseViewModel import com.nextroom.nextroom.presentation.model.UiText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.orbitmvi.orbit.Container -import org.orbitmvi.orbit.syntax.simple.intent -import org.orbitmvi.orbit.syntax.simple.postSideEffect -import org.orbitmvi.orbit.syntax.simple.reduce -import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class EmailLoginViewModel @Inject constructor( private val adminRepository: AdminRepository, -) : BaseViewModel() { +) : NewBaseViewModel() { + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent = _uiEvent.asSharedFlow() - override val container: Container = container(EmailLoginState()) val loginState: StateFlow = adminRepository.loggedIn.stateIn( viewModelScope, SharingStarted.Eagerly, false, ) - private var idSaveChecked = false + private var emailSaveChecked = false init { - baseViewModelScope.launch { - adminRepository.loggedIn.collect { - if (it) verifySuccess() // 로그인 됐으면 테마 목록으로 이동 - } - } + checkEmailSaved() } - fun checkEmailSaved() { + private fun checkEmailSaved() { baseViewModelScope.launch { - intent { - val idSaveChecked = adminRepository.getEmailSaveChecked() - val userEmail = if (idSaveChecked) adminRepository.getUserEmail() else "" - reduce { - state.copy( - idSaveChecked = idSaveChecked, - userEmail = userEmail - ) - } + val saved = adminRepository.getEmailSaveChecked() + val userEmail = if (saved) adminRepository.getUserEmail() else "" + emailSaveChecked = saved + _uiState.update { + it.copy(emailSaveChecked = saved, currentEmailInput = userEmail) } } } - fun inputCode(code: String) = intent { - reduce { - state.copy( - currentIdInput = code, - idInputState = if (code.isNotBlank()) InputState.Typing else InputState.Empty, - ) - } + fun inputEmail(email: String) { + _uiState.update { it.copy(currentEmailInput = email, hasError = false) } } - fun inputPassword(pw: String) = intent { - reduce { - state.copy( - currentPasswordInput = pw, - passwordInputState = if (pw.isNotBlank()) InputState.Typing else InputState.Empty, - ) - } + fun inputPassword(password: String) { + _uiState.update { it.copy(currentPasswordInput = password, hasError = false) } } - fun onIdSaveChecked(checked: Boolean) { - idSaveChecked = checked + fun onEmailSaveChecked(checked: Boolean) { + emailSaveChecked = checked + _uiState.update { it.copy(emailSaveChecked = checked) } } - fun complete() = intent { - reduce { state.copy(loading = true) } - adminRepository.login(state.currentIdInput, state.currentPasswordInput, idSaveChecked) - .onSuccess { - verifySuccess() - }.onFailure { - reduce { state.copy(idInputState = InputState.Error(R.string.blank)) } - when (it) { - is Result.Failure.HttpError -> event(EmailLoginEvent.EmailLoginFailed(it.message)) - is Result.Failure.NetworkError -> showMessage(R.string.error_network) - else -> showMessage(R.string.error_something) + fun complete() { + val state = _uiState.value + baseViewModelScope.launch { + _uiState.update { it.copy(loading = true, hasError = false) } + adminRepository.login( + state.currentEmailInput, + state.currentPasswordInput, + emailSaveChecked + ) + .onSuccess { + // loggedIn flow handles navigation + }.onFailure { + _uiState.update { current -> current.copy(hasError = true) } + when (it) { + is Result.Failure.HttpError -> _uiEvent.emit(UiEvent.EmailLoginFailed(it.message)) + is Result.Failure.NetworkError -> showMessage(R.string.error_network) + else -> showMessage(R.string.error_something) + } + }.onFinally { + _uiState.update { it.copy(loading = false) } } - }.onFinally { - reduce { state.copy(loading = false) } - } - } - - fun initState() = intent { - reduce { - state.copy(idInputState = InputState.Empty, passwordInputState = InputState.Empty) } } - private fun verifySuccess() = intent { - reduce { - state.copy(idInputState = InputState.Ok, passwordInputState = InputState.Ok) - } - } - - private fun showMessage(message: String) = intent { - postSideEffect(EmailLoginEvent.ShowMessage(UiText(message))) - } - - private fun showMessage(@StringRes messageId: Int) = intent { - postSideEffect(EmailLoginEvent.ShowMessage(UiText(messageId))) - } - - private fun event(event: EmailLoginEvent) { - when (event) { - is EmailLoginEvent.EmailLoginFailed -> showMessage(event.message) - else -> {} - } - } - - fun requestGoogleAuth() = intent { + fun requestGoogleAuth() { baseViewModelScope.launch { - reduce { state.copy(loading = true) } + _uiState.update { it.copy(loading = true) } try { adminRepository.requestGoogleAuth().getOrThrow .also { postGoogleLogin(it.idToken) } } catch (e: GetCredentialCancellationException) { // do nothing } catch (e: Exception) { - postSideEffect(EmailLoginEvent.GoogleAuthFailed) + _uiEvent.emit(UiEvent.GoogleAuthFailed) } - reduce { state.copy(loading = false) } + _uiState.update { it.copy(loading = false) } } } - private fun postGoogleLogin(idToken: String) = intent { + private fun postGoogleLogin(idToken: String) { baseViewModelScope.launch { try { adminRepository.postGoogleLogin(idToken).getOrThrow.let { if (!it.isComplete) { - postSideEffect(EmailLoginEvent.NeedAdditionalUserInfo(it.shopName)) + _uiEvent.emit(UiEvent.NeedAdditionalUserInfo(it.shopName)) } } } catch (e: Exception) { - postSideEffect(EmailLoginEvent.GoogleLoginFailed) + _uiEvent.emit(UiEvent.GoogleLoginFailed) } } } + + private suspend fun showMessage(@StringRes messageId: Int) { + _uiEvent.emit(UiEvent.ShowMessage(UiText(messageId))) + } + + data class UiState( + val loading: Boolean = false, + val currentEmailInput: String = "", + val currentPasswordInput: String = "", + val emailSaveChecked: Boolean = false, + val hasError: Boolean = false, + ) + + sealed interface UiEvent { + data class EmailLoginFailed(val message: String) : UiEvent + data class ShowMessage(val message: UiText) : UiEvent + data object GoogleAuthFailed : UiEvent + data object GoogleLoginFailed : UiEvent + data class NeedAdditionalUserInfo(val shopName: String?) : UiEvent + } } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupFragment.kt index b747380e..8ff58f64 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupFragment.kt @@ -1,13 +1,15 @@ package com.nextroom.nextroom.presentation.ui.login import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.EditText -import android.widget.TextView +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.BundleCompat import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels @@ -15,75 +17,48 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.nextroom.nextroom.presentation.NavGraphDirections import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseViewModelFragment -import com.nextroom.nextroom.presentation.databinding.FragmentSignupBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.BUNDLE_KEY_RESULT_DATA import com.nextroom.nextroom.presentation.extension.hasResultData -import com.nextroom.nextroom.presentation.extension.inputMethodManager import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate import com.nextroom.nextroom.presentation.extension.toast import com.nextroom.nextroom.presentation.model.SelectItemBottomSheetArg +import com.nextroom.nextroom.presentation.ui.login.compose.SignupScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class SignupFragment : BaseViewModelFragment(FragmentSignupBinding::inflate), - View.OnClickListener { +class SignupFragment : ComposeBaseViewModelFragment() { override val screenName = "signup" override val viewModel: SignupViewModel by viewModels() - val args: SignupFragmentArgs by navArgs() - - override fun initViews() { - super.initViews() - - binding.tvSignupComplete.isEnabled = false - } - - override fun initListeners() { - super.initListeners() - - fun setEditTextFocusSettings(editText: EditText) { - editText.setOnFocusChangeListener { v, hasFocus -> - if (!hasFocus) { - requireActivity().inputMethodManager?.hideSoftInputFromWindow(v.windowToken, 0) - editText.clearFocus() - } - - if (hasFocus) { - R.drawable.bg_black_border_white50_r8 - } else { - R.drawable.bg_black_border_white20_r8 - }.also { - editText.setBackgroundResource(it) - } + private val args: SignupFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + SignupScreen( + state = state, + onBackClick = { findNavController().navigateUp() }, + onShopNameChange = viewModel::onShopNameChanged, + onSignupSourceClick = ::showSelectSignupSourceBottomSheet, + onCustomSignupSourceChange = viewModel::setCustomSignupSource, + onSignupReasonClick = ::showSelectSignupReasonBottomSheet, + onCustomSignupReasonChange = viewModel::setCustomSignupReason, + onAllTermsAgreeClick = viewModel::onAllTermsAgreeClicked, + onServiceTermAgreeClick = viewModel::setServiceTermAgree, + onMarketingTermAgreeClick = viewModel::setMarketingTermAgree, + onServiceTermLinkClick = ::moveToServiceTermWebView, + onSignupClick = viewModel::signup, + ) } - editText.setOnEditorActionListener { _, _, _ -> - editText.clearFocus() - false - } - } - - binding.ivBack.setOnClickListener(this) - binding.llSignupSource.setOnClickListener(this) - binding.llSignupReason.setOnClickListener(this) - binding.clAgreeAllTerms.setOnClickListener(this) - binding.tvServiceTermAgree.setOnClickListener(this) - binding.llServiceTermAgree.setOnClickListener(this) - binding.llMarketingTermAgree.setOnClickListener(this) - binding.tvSignupComplete.setOnClickListener(this) - binding.etShopName.addTextChangedListener { - viewModel.onShopNameChanged(it.toString()) - } - binding.etSignupSource.addTextChangedListener { - viewModel.setCustomSignupSource(it.toString()) } - binding.etSignupReason.addTextChangedListener { - viewModel.setCustomSignupReason(it.toString()) - } - setEditTextFocusSettings(binding.etShopName) - setEditTextFocusSettings(binding.etSignupSource) - setEditTextFocusSettings(binding.etSignupReason) } override fun setFragmentResultListeners() { @@ -96,7 +71,7 @@ class SignupFragment : BaseViewModelFragment - when (state) { - is SignupViewModel.UIState.Loaded -> updateUI(state) - SignupViewModel.UIState.Loading -> Unit - } - binding.pbLoading.isVisible = state is SignupViewModel.UIState.Loading - } - } launch { viewModel.uiEvent.collect { event -> when (event) { @@ -156,35 +118,14 @@ class SignupFragment : BaseViewModelFragment SelectItemBottomSheetArg.Item( id = index.toString(), text = s, - isSelected = index == selectedItem?.id?.toIntOrNull() + isSelected = index == selected?.id?.toIntOrNull() ) }.let { SelectItemBottomSheetArg( @@ -197,12 +138,14 @@ class SignupFragment : BaseViewModelFragment SelectItemBottomSheetArg.Item( id = index.toString(), text = s, - isSelected = index == selectedItem?.id?.toIntOrNull() + isSelected = index == selected?.id?.toIntOrNull() ) }.let { SelectItemBottomSheetArg( @@ -221,31 +164,8 @@ class SignupFragment : BaseViewModelFragment findNavController().navigateUp() - binding.llSignupSource -> { - binding.etShopName.clearFocus() - (viewModel.uiState.value as? SignupViewModel.UIState.Loaded)?.let { loaded -> - showSelectSignupSourceBottomSheet(loaded.selectedSignupSource) - } - } - binding.llSignupReason -> { - binding.etShopName.clearFocus() - (viewModel.uiState.value as? SignupViewModel.UIState.Loaded)?.let { loaded -> - showSelectSignupReasonBottomSheet(loaded.selectedSignupReason) - } - } - binding.clAgreeAllTerms -> viewModel.onAllTermsAgreeClicked(binding.cbAgreeAllTerms.isChecked.not()) - binding.tvServiceTermAgree -> moveToServiceTermWebView() - binding.llServiceTermAgree -> viewModel.setServiceTermAgree(binding.cbServiceTermAgree.isChecked.not()) - binding.llMarketingTermAgree -> viewModel.setMarketingTermAgree(binding.cbMarketingTermsAgree.isChecked.not()) - binding.tvSignupComplete -> viewModel.signup() - } - } - companion object { const val SELECT_SIGNUP_SOURCE_REQUEST_KEY = "SELECT_SIGNUP_SOURCE_REQUEST_KEY" const val SELECT_SIGNUP_REASON_REQUEST_KEY = "SELECT_SIGNUP_REASON_REQUEST_KEY" } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupViewModel.kt index bd30eae3..2549cfa3 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/SignupViewModel.kt @@ -28,28 +28,40 @@ class SignupViewModel @Inject constructor( private val _marketingTermAgree = MutableStateFlow(false) private val _apiLoading = MutableStateFlow(false) - val uiState = combine( + private val inputs = combine( _shopName, _selectedSignupSource, _selectedSignupReason, - _serviceTermAgree, - _marketingTermAgree, - ) { shopName, selectedSignupSource, selectedSignupReason, serviceTermAgree, marketingTermAgree -> - UIState.Loaded( + _customSignupSource, + _customSignupReason, + ) { shopName, selectedSignupSource, selectedSignupReason, customSignupSource, customSignupReason -> + Inputs( shopName = shopName, selectedSignupSource = selectedSignupSource, selectedSignupReason = selectedSignupReason, + customSignupSource = customSignupSource, + customSignupReason = customSignupReason, + ) + } + + private val terms = combine(_serviceTermAgree, _marketingTermAgree) { service, marketing -> + service to marketing + } + + val uiState = combine(inputs, terms) { input, (serviceTermAgree, marketingTermAgree) -> + UIState.Loaded( + shopName = input.shopName, + selectedSignupSource = input.selectedSignupSource, + selectedSignupReason = input.selectedSignupReason, + customSignupSource = input.customSignupSource, + customSignupReason = input.customSignupReason, serviceTermAgreed = serviceTermAgree, marketingTermAgreed = marketingTermAgree, allTermsAgreed = serviceTermAgree && marketingTermAgree, - allRequiredFieldFilled = !shopName.isNullOrEmpty() && selectedSignupSource != null && serviceTermAgree + allRequiredFieldFilled = !input.shopName.isNullOrEmpty() && input.selectedSignupSource != null && serviceTermAgree ) }.combine(_apiLoading) { loaded, loading -> - if (loading) { - UIState.Loading - } else { - loaded - } + if (loading) UIState.Loading else loaded }.stateIn(baseViewModelScope, SharingStarted.Lazily, UIState.Loading) private val _uiEvent = MutableSharedFlow() @@ -115,12 +127,22 @@ class SignupViewModel @Inject constructor( } } + private data class Inputs( + val shopName: String?, + val selectedSignupSource: UIState.Loaded.SelectedItem?, + val selectedSignupReason: UIState.Loaded.SelectedItem?, + val customSignupSource: String?, + val customSignupReason: String?, + ) + sealed interface UIState { data object Loading : UIState data class Loaded( val shopName: String?, val selectedSignupSource: SelectedItem?, val selectedSignupReason: SelectedItem?, + val customSignupSource: String?, + val customSignupReason: String?, val serviceTermAgreed: Boolean, val marketingTermAgreed: Boolean, val allTermsAgreed: Boolean, diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/EmailLoginScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/EmailLoginScreen.kt new file mode 100644 index 00000000..cf61a461 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/EmailLoginScreen.kt @@ -0,0 +1,382 @@ +package com.nextroom.nextroom.presentation.ui.login.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.ui.login.EmailLoginViewModel + +@Composable +fun EmailLoginScreen( + state: EmailLoginViewModel.UiState, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onEmailSaveCheckedChange: (Boolean) -> Unit, + onLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + onBackClick: () -> Unit, + onCustomerServiceClick: () -> Unit, + onSignupClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01), + ) { + Image( + painter = painterResource(R.drawable.bg_login), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .alpha(0.4f), + contentScale = ContentScale.FillWidth, + ) + + Column(modifier = Modifier.fillMaxSize()) { + TopBar(onBackClick = onBackClick) + + Spacer(modifier = Modifier.height(44.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + LoginTextField( + value = state.currentEmailInput, + onValueChange = onEmailChange, + hint = stringResource(R.string.login_admin_email_hint), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + isError = state.hasError, + enabled = !state.loading, + ) + Spacer(modifier = Modifier.height(16.dp)) + LoginTextField( + value = state.currentPasswordInput, + onValueChange = onPasswordChange, + hint = stringResource(R.string.login_password_hint), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + isPassword = true, + onImeAction = onLoginClick, + isError = state.hasError, + enabled = !state.loading, + ) + Spacer(modifier = Modifier.height(16.dp)) + EmailSaveCheckBox( + checked = state.emailSaveChecked, + onCheckedChange = onEmailSaveCheckedChange, + ) + Spacer(modifier = Modifier.height(40.dp)) + EmailLoginButton( + enabled = !state.loading, + onClick = onLoginClick, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + GoogleLoginButton( + onClick = onGoogleLoginClick, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + BottomLinks( + onCustomerServiceClick = onCustomerServiceClick, + onSignupClick = onSignupClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + NRLoading(isVisible = state.loading) + } +} + +@Composable +private fun TopBar(onBackClick: () -> Unit) { + Box(modifier = Modifier.fillMaxWidth()) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false), + onClick = onBackClick, + ) + .padding(20.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_navigate_back_24), + contentDescription = stringResource(R.string.toolbar_navigate_back_description), + tint = NRColor.White, + ) + } + Text( + text = stringResource(R.string.text_email_login), + style = NRTypo.Pretendard.size20, + color = NRColor.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +private fun LoginTextField( + value: String, + onValueChange: (String) -> Unit, + hint: String, + keyboardType: KeyboardType, + imeAction: ImeAction, + isError: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + isPassword: Boolean = false, + onImeAction: () -> Unit = {}, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val underlineColor = when { + isError -> NRColor.Red + isFocused || value.isNotEmpty() -> NRColor.White + else -> NRColor.Gray01 + } + val textColor = if (isFocused || value.isNotEmpty()) NRColor.White else NRColor.Gray01 + + Column(modifier = modifier) { + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = NRTypo.Pretendard.size16.copy(color = textColor), + cursorBrush = androidx.compose.ui.graphics.SolidColor(NRColor.White), + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions( + onDone = { onImeAction() }, + onGo = { onImeAction() }, + ), + visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = hint, + style = NRTypo.Pretendard.size16, + color = NRColor.Gray01, + ) + } + innerTextField() + }, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(underlineColor), + ) + } +} + +@Composable +private fun EmailSaveCheckBox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onCheckedChange(!checked) }, + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = CheckboxDefaults.colors( + checkedColor = NRColor.White, + uncheckedColor = NRColor.Gray01, + checkmarkColor = NRColor.Black, + ), + ) + Text( + text = stringResource(R.string.email_save_title), + style = NRTypo.Pretendard.size14, + color = NRColor.Gray01, + ) + } +} + +@Composable +private fun EmailLoginButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .background(NRColor.White) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.text_login), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.Black, + ) + } +} + +@Composable +private fun GoogleLoginButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .background(NRColor.Black) + .border( + width = 1.dp, + color = NRColor.White20, + shape = RoundedCornerShape(100.dp), + ) + .clickable(onClick = onClick) + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_google), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.text_start_with_google), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.White, + ) + } +} + +@Composable +private fun BottomLinks( + onCustomerServiceClick: () -> Unit, + onSignupClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.text_customer_service), + style = NRTypo.Pretendard.size12, + color = NRColor.Gray01, + modifier = Modifier + .clickable(onClick = onCustomerServiceClick) + .padding(16.dp), + ) + Box( + modifier = Modifier + .width(1.dp) + .height(12.dp) + .background(NRColor.Gray02), + ) + Text( + text = stringResource(R.string.sign_up), + style = NRTypo.Pretendard.size12, + color = NRColor.Gray01, + modifier = Modifier + .clickable(onClick = onSignupClick) + .padding(16.dp), + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 800) +@Composable +private fun EmailLoginScreenPreview() { + EmailLoginScreen( + state = EmailLoginViewModel.UiState(), + onEmailChange = {}, + onPasswordChange = {}, + onEmailSaveCheckedChange = {}, + onLoginClick = {}, + onGoogleLoginClick = {}, + onBackClick = {}, + onCustomerServiceClick = {}, + onSignupClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 800) +@Composable +private fun EmailLoginScreenFilledPreview() { + EmailLoginScreen( + state = EmailLoginViewModel.UiState( + currentEmailInput = "test@nextroom.co.kr", + currentPasswordInput = "password", + emailSaveChecked = true, + hasError = true, + ), + onEmailChange = {}, + onPasswordChange = {}, + onEmailSaveCheckedChange = {}, + onLoginClick = {}, + onGoogleLoginClick = {}, + onBackClick = {}, + onCustomerServiceClick = {}, + onSignupClick = {}, + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/SignupScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/SignupScreen.kt new file mode 100644 index 00000000..071189bf --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/login/compose/SignupScreen.kt @@ -0,0 +1,529 @@ +package com.nextroom.nextroom.presentation.ui.login.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.ui.login.SignupViewModel + +@Composable +fun SignupScreen( + state: SignupViewModel.UIState, + onBackClick: () -> Unit, + onShopNameChange: (String) -> Unit, + onSignupSourceClick: () -> Unit, + onCustomSignupSourceChange: (String) -> Unit, + onSignupReasonClick: () -> Unit, + onCustomSignupReasonChange: (String) -> Unit, + onAllTermsAgreeClick: (Boolean) -> Unit, + onServiceTermAgreeClick: (Boolean) -> Unit, + onMarketingTermAgreeClick: (Boolean) -> Unit, + onServiceTermLinkClick: () -> Unit, + onSignupClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val loaded = state as? SignupViewModel.UIState.Loaded + val isLoading = state is SignupViewModel.UIState.Loading + val etcText = stringResource(R.string.text_etc) + val isCustomSourceVisible = loaded?.selectedSignupSource?.text == etcText + val isCustomReasonVisible = loaded?.selectedSignupReason?.text == etcText + + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01), + ) { + Image( + painter = painterResource(R.drawable.bg_login), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .alpha(0.4f), + contentScale = ContentScale.FillWidth, + ) + + Column(modifier = Modifier.fillMaxSize()) { + TopBar(onBackClick = onBackClick) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding( + PaddingValues( + start = 20.dp, + end = 20.dp, + top = 60.dp, + bottom = 140.dp, + ) + ), + ) { + LabelWithAsterisk(text = stringResource(R.string.text_shop_name)) + Spacer(modifier = Modifier.height(12.dp)) + SignupInputField( + value = loaded?.shopName.orEmpty(), + onValueChange = onShopNameChange, + hint = stringResource(R.string.text_please_input), + ) + + Spacer(modifier = Modifier.height(16.dp)) + LabelWithAsterisk(text = stringResource(R.string.text_singup_source)) + Spacer(modifier = Modifier.height(12.dp)) + SelectField( + text = loaded?.selectedSignupSource?.text, + onClick = onSignupSourceClick, + ) + if (isCustomSourceVisible) { + Spacer(modifier = Modifier.height(12.dp)) + SignupInputField( + value = loaded?.customSignupSource.orEmpty(), + onValueChange = onCustomSignupSourceChange, + hint = stringResource(R.string.text_please_input), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.text_singup_reason), + style = NRTypo.Pretendard.size16, + color = NRColor.White70, + ) + Spacer(modifier = Modifier.height(12.dp)) + SelectField( + text = loaded?.selectedSignupReason?.text, + onClick = onSignupReasonClick, + ) + if (isCustomReasonVisible) { + Spacer(modifier = Modifier.height(12.dp)) + SignupInputField( + value = loaded?.customSignupReason.orEmpty(), + onValueChange = onCustomSignupReasonChange, + hint = stringResource(R.string.text_please_input), + ) + } + + Spacer(modifier = Modifier.height(52.dp)) + AgreeAllTermsRow( + checked = loaded?.allTermsAgreed == true, + onClick = { onAllTermsAgreeClick(loaded?.allTermsAgreed != true) }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + ServiceTermAgreeRow( + checked = loaded?.serviceTermAgreed == true, + onRowClick = { onServiceTermAgreeClick(loaded?.serviceTermAgreed != true) }, + onLinkClick = onServiceTermLinkClick, + ) + + MarketingTermAgreeRow( + checked = loaded?.marketingTermAgreed == true, + onClick = { onMarketingTermAgreeClick(loaded?.marketingTermAgreed != true) }, + ) + } + + SignupCompleteButton( + enabled = loaded?.allRequiredFieldFilled == true, + onClick = onSignupClick, + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 50.dp), + ) + } + + NRLoading(isVisible = isLoading) + } +} + +@Composable +private fun TopBar(onBackClick: () -> Unit) { + Box(modifier = Modifier.fillMaxWidth()) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false), + onClick = onBackClick, + ) + .padding(20.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_navigate_back_24), + contentDescription = stringResource(R.string.toolbar_navigate_back_description), + tint = NRColor.White, + ) + } + Text( + text = stringResource(R.string.sign_up), + style = NRTypo.Pretendard.size20, + color = NRColor.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +private fun LabelWithAsterisk(text: String) { + Row { + Text( + text = text, + style = NRTypo.Pretendard.size16, + color = NRColor.White70, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = stringResource(R.string.text_asterisk), + style = NRTypo.Pretendard.size16, + color = NRColor.Red02, + ) + } +} + +@Composable +private fun SignupInputField( + value: String, + onValueChange: (String) -> Unit, + hint: String, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor = if (isFocused) NRColor.White50 else NRColor.White20 + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.Black) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = NRTypo.Pretendard.size16.copy(color = NRColor.White), + cursorBrush = SolidColor(NRColor.White), + interactionSource = interactionSource, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.fillMaxWidth(), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = hint, + style = NRTypo.Pretendard.size16, + color = NRColor.Gray01, + ) + } + innerTextField() + }, + ) + } +} + +@Composable +private fun SelectField( + text: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.Black) + .border( + width = 1.dp, + color = NRColor.White20, + shape = RoundedCornerShape(8.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text ?: stringResource(R.string.text_please_select), + style = NRTypo.Pretendard.size16, + color = if (text == null) NRColor.Gray01 else NRColor.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + Image( + painter = painterResource(R.drawable.ic_arrow_down), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun AgreeAllTermsRow( + checked: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.White12) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.text_agree_all_terms), + style = NRTypo.Pretendard.size14, + color = NRColor.White, + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = checked, + onCheckedChange = null, + colors = signupCheckboxColors(), + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun ServiceTermAgreeRow( + checked: Boolean, + onRowClick: () -> Unit, + onLinkClick: () -> Unit, +) { + val linkPortion = stringResource(R.string.text_service_term_agree_link) + val suffix = stringResource(R.string.text_service_term_agree_suffix) + val annotated = buildAnnotatedString { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append(linkPortion) + } + append(suffix) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onRowClick) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = annotated, + style = NRTypo.Pretendard.size12, + color = NRColor.White, + modifier = Modifier.clickable(onClick = onLinkClick), + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = stringResource(R.string.text_required_label), + style = NRTypo.Pretendard.size12, + color = NRColor.Red02, + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = checked, + onCheckedChange = null, + colors = signupCheckboxColors(), + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun MarketingTermAgreeRow( + checked: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.text_marketing_term_agree), + style = NRTypo.Pretendard.size12, + color = NRColor.White, + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = checked, + onCheckedChange = null, + colors = signupCheckboxColors(), + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun SignupCompleteButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = + if (enabled) NRColor.PrimaryButtonBackground else NRColor.DisabledButtonBackground + val textColor = if (enabled) NRColor.PrimaryButtonText else NRColor.DisabledButtonText + + Box( + modifier = modifier + .height(60.dp) + .clip(RoundedCornerShape(100.dp)) + .background(backgroundColor) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.text_signup_complete), + style = NRTypo.Pretendard.size16Bold, + color = textColor, + ) + } +} + +@Composable +private fun signupCheckboxColors() = CheckboxDefaults.colors( + checkedColor = NRColor.White, + uncheckedColor = NRColor.Gray01, + checkmarkColor = NRColor.Black, +) + +@Composable +fun rememberSignupSourceItems(): List = + stringArrayResource(R.array.signup_source).toList() + +@Composable +fun rememberSignupReasonItems(): List = + stringArrayResource(R.array.signup_reason).toList() + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 900) +@Composable +private fun SignupScreenEmptyPreview() { + SignupScreen( + state = SignupViewModel.UIState.Loaded( + shopName = null, + selectedSignupSource = null, + selectedSignupReason = null, + customSignupSource = null, + customSignupReason = null, + serviceTermAgreed = false, + marketingTermAgreed = false, + allTermsAgreed = false, + allRequiredFieldFilled = false, + ), + onBackClick = {}, + onShopNameChange = {}, + onSignupSourceClick = {}, + onCustomSignupSourceChange = {}, + onSignupReasonClick = {}, + onCustomSignupReasonChange = {}, + onAllTermsAgreeClick = {}, + onServiceTermAgreeClick = {}, + onMarketingTermAgreeClick = {}, + onServiceTermLinkClick = {}, + onSignupClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 900) +@Composable +private fun SignupScreenFilledPreview() { + SignupScreen( + state = SignupViewModel.UIState.Loaded( + shopName = "NextRoom 강남점", + selectedSignupSource = SignupViewModel.UIState.Loaded.SelectedItem( + id = "5", + text = "기타", + ), + selectedSignupReason = SignupViewModel.UIState.Loaded.SelectedItem( + id = "0", + text = "운영 중인 매장에 도입하기 위해", + ), + customSignupSource = "지하철 광고", + customSignupReason = null, + serviceTermAgreed = true, + marketingTermAgreed = true, + allTermsAgreed = true, + allRequiredFieldFilled = true, + ), + onBackClick = {}, + onShopNameChange = {}, + onSignupSourceClick = {}, + onCustomSignupSourceChange = {}, + onSignupReasonClick = {}, + onCustomSignupReasonChange = {}, + onAllTermsAgreeClick = {}, + onServiceTermAgreeClick = {}, + onMarketingTermAgreeClick = {}, + onServiceTermLinkClick = {}, + onSignupClick = {}, + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt index da2a87eb..280b0453 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/LoginFragment.kt @@ -1,52 +1,57 @@ package com.nextroom.nextroom.presentation.ui.onboarding import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isVisible +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import com.nextroom.nextroom.presentation.base.BaseViewModelFragment -import com.nextroom.nextroom.presentation.databinding.FragmentLoginBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.ui.onboarding.compose.LoginScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class LoginFragment : BaseViewModelFragment(FragmentLoginBinding::inflate), - View.OnClickListener { +class LoginFragment : ComposeBaseViewModelFragment() { override val screenName = "login" override val viewModel: LoginViewModel by viewModels() - override fun initListeners() { - super.initListeners() - binding.tvStartWithEmail.setOnClickListener(this) - binding.llStartWithGoogle.setOnClickListener(this) - binding.tvTryWithoutLogin.setOnClickListener(this) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val isLoading by viewModel.apiLoading.collectAsState() + LoginScreen( + isLoading = isLoading, + onGoogleLoginClick = viewModel::requestGoogleAuth, + onEmailLoginClick = ::moveToEmailLogin, + onTryWithoutLoginClick = ::moveToTutorial, + ) + } + } } override fun setFragmentResultListeners() { super.setFragmentResultListeners() - setFragmentResultListener(SIGNUP_REQUEST_KEY, ::handleFragmentResults) - } - - private fun handleFragmentResults(requestKey: String, bundle: Bundle) { - when (requestKey) { - SIGNUP_REQUEST_KEY -> moveToThemeSelect() + setFragmentResultListener(SIGNUP_REQUEST_KEY) { _, _ -> + moveToThemeSelect() } } - override fun initObserve() { - super.initObserve() - + override fun initSubscribe() { viewLifecycleOwner.repeatOnStarted { - launch { - viewModel.apiLoading.collect { - binding.pbLoading.isVisible = it - } - } launch { viewModel.uiEvent.collect { event -> when (event) { @@ -78,15 +83,7 @@ class LoginFragment : BaseViewModelFragment moveToEmailLogin() - binding.llStartWithGoogle -> viewModel.requestGoogleAuth() - binding.tvTryWithoutLogin -> moveToTutorial() - } - } - companion object { const val SIGNUP_REQUEST_KEY = "SIGNUP_REQUEST_KEY" } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/compose/LoginScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/compose/LoginScreen.kt new file mode 100644 index 00000000..175330a1 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/onboarding/compose/LoginScreen.kt @@ -0,0 +1,221 @@ +package com.nextroom.nextroom.presentation.ui.onboarding.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.common.compose.NRTypo + +@Composable +fun LoginScreen( + isLoading: Boolean, + onGoogleLoginClick: () -> Unit, + onEmailLoginClick: () -> Unit, + onTryWithoutLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(92.dp)) + Text( + text = stringResource(R.string.onboarding_description), + style = NRTypo.Pretendard.size20, + color = NRColor.Gray01, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onboarding_title), + style = NRTypo.Pretendard.size32Bold, + ) + Spacer(modifier = Modifier.height(32.dp)) + Image( + painter = painterResource(R.drawable.bg_onboarding), + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + } + + Image( + painter = painterResource(R.drawable.bg_black_to_white_gradient), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + contentScale = ContentScale.FillWidth, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LoginGuideLabel( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + GoogleLoginButton( + onClick = onGoogleLoginClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + EmailLoginButton( + onClick = onEmailLoginClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.text_try_without_login), + style = NRTypo.Pretendard.size14, + color = NRColor.Gray01, + modifier = Modifier + .clickable(onClick = onTryWithoutLoginClick) + .padding(8.dp), + ) + Spacer(modifier = Modifier.height(32.dp)) + } + + NRLoading(isVisible = isLoading) + } +} + +@Composable +private fun LoginGuideLabel(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = 1.dp, + color = NRColor.White12, + ) + Text( + text = stringResource(R.string.text_recommend_google_login_guide), + style = NRTypo.Pretendard.size14, + color = NRColor.White, + modifier = Modifier.padding(horizontal = 8.dp), + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = 1.dp, + color = NRColor.White12, + ) + } +} + +@Composable +private fun GoogleLoginButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .background(NRColor.White) + .clickable(onClick = onClick) + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_google), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.text_start_with_google), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.Black, + ) + } +} + +@Composable +private fun EmailLoginButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .background(NRColor.Black) + .border( + width = 1.dp, + color = NRColor.White20, + shape = RoundedCornerShape(100.dp), + ) + .clickable(onClick = onClick) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.text_start_with_email), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.White, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 800) +@Composable +private fun LoginScreenPreview() { + LoginScreen( + isLoading = false, + onGoogleLoginClick = {}, + onEmailLoginClick = {}, + onTryWithoutLoginClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 800) +@Composable +private fun LoginScreenLoadingPreview() { + LoginScreen( + isLoading = true, + onGoogleLoginClick = {}, + onEmailLoginClick = {}, + onTryWithoutLoginClick = {}, + ) +} diff --git a/presentation/src/main/res/layout/fragment_email_login.xml b/presentation/src/main/res/layout/fragment_email_login.xml deleted file mode 100644 index a859a3f9..00000000 --- a/presentation/src/main/res/layout/fragment_email_login.xml +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_login.xml b/presentation/src/main/res/layout/fragment_login.xml deleted file mode 100644 index a8747778..00000000 --- a/presentation/src/main/res/layout/fragment_login.xml +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_signup.xml b/presentation/src/main/res/layout/fragment_signup.xml deleted file mode 100644 index 7a83fdb4..00000000 --- a/presentation/src/main/res/layout/fragment_signup.xml +++ /dev/null @@ -1,369 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/navigation/nav_graph.xml b/presentation/src/main/res/navigation/nav_graph.xml index 1da82b48..4d6fecc4 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -187,8 +187,7 @@ + android:name="com.nextroom.nextroom.presentation.ui.login.EmailLoginFragment"> + android:name="com.nextroom.nextroom.presentation.ui.onboarding.LoginFragment"> + android:name="com.nextroom.nextroom.presentation.ui.login.SignupFragment"> 가입 이유 모두 동의합니다. 서비스 이용약관 동의 + 서비스 이용약관 + " 동의" (필수) 새로운 업데이트 소식 받기 가입 경로 선택