diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordFragment.kt index d96286d8..776c1aa8 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordFragment.kt @@ -2,56 +2,71 @@ package com.nextroom.nextroom.presentation.ui.password import android.os.Build import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.biometric.BiometricPrompt +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.content.ContextCompat import androidx.core.os.bundleOf -import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.nextroom.nextroom.presentation.R -import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.databinding.FragmentCheckPasswordBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.BUNDLE_KEY_RESULT_DATA import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.toast +import com.nextroom.nextroom.presentation.ui.password.compose.CheckPasswordScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class CheckPasswordFragment : BaseFragment(FragmentCheckPasswordBinding::inflate) { +class CheckPasswordFragment : ComposeBaseViewModelFragment() { - private val viewModel: CheckPasswordViewModel by viewModels() + override val screenName: String = "check_password" + override val viewModel: CheckPasswordViewModel by viewModels() private val args: CheckPasswordFragmentArgs by navArgs() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + private val showBiometric: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - initView() - initListener() - initSubscribe() - showBiometricPrompt() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + CheckPasswordScreen( + state = state, + onKeyClick = viewModel::onNumberClicked, + onBackspaceClick = viewModel::onBackSpaceClicked, + onBackClick = { findNavController().navigateUp() }, + onBiometricClick = ::showBiometricPrompt, + showBiometric = showBiometric, + ) + } + } } - private fun initView() { - binding.keyBiometric.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + showBiometricPrompt() } - private fun initSubscribe() { + override fun initSubscribe() { viewLifecycleOwner.repeatOnStarted { - launch { - viewModel.inputPassword.collect { password -> - updateUi(password) - } - } launch { viewModel.uiEvent.collect { event -> when (event) { CheckPasswordViewModel.UiEvent.PasswordCorrect -> onPasswordCorrected() CheckPasswordViewModel.UiEvent.PasswordInCorrect -> { - binding.customCodeInput.setError() toast(getString(R.string.text_incorrect_password_error_message)) } } @@ -60,26 +75,20 @@ class CheckPasswordFragment : BaseFragment(Fragmen } } - private fun updateUi(password: String) { - binding.customCodeInput.setCode(password) - } - private fun showBiometricPrompt() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return val executor = ContextCompat.getMainExecutor(requireContext()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onPasswordCorrected() - } - }).also { biometricPrompt -> - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.text_finger_print_auth)) - .setNegativeButtonText(getString(R.string.text_cancel)) - .build() - - biometricPrompt.authenticate(promptInfo) + BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onPasswordCorrected() } + }).also { biometricPrompt -> + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.text_finger_print_auth)) + .setNegativeButtonText(getString(R.string.text_cancel)) + .build() + biometricPrompt.authenticate(promptInfo) } } @@ -90,20 +99,4 @@ class CheckPasswordFragment : BaseFragment(Fragmen ) findNavController().popBackStack() } - - private fun initListener() { - binding.tvKey1.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_1)) } - binding.tvKey2.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_2)) } - binding.tvKey3.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_3)) } - binding.tvKey4.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_4)) } - binding.tvKey5.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_5)) } - binding.tvKey6.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_6)) } - binding.tvKey7.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_7)) } - binding.tvKey8.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_8)) } - binding.tvKey9.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_9)) } - binding.tvKey0.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_0)) } - binding.keyBiometric.setOnClickListener { showBiometricPrompt() } - binding.keyBackspace.setOnClickListener { viewModel.onBackSpaceClicked() } - binding.ivBack.setOnClickListener { findNavController().navigateUp() } - } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordViewModel.kt index 0202e6f4..65f6ffa7 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/CheckPasswordViewModel.kt @@ -1,67 +1,75 @@ package com.nextroom.nextroom.presentation.ui.password -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.nextroom.nextroom.domain.repository.AdminRepository +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.model.InputState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CheckPasswordViewModel @Inject constructor( - val adminRepository: AdminRepository -) : ViewModel() { - val inputPassword = MutableStateFlow("") - private val _uiEvent = MutableSharedFlow() + private val adminRepository: AdminRepository +) : NewBaseViewModel() { + + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) val uiEvent = _uiEvent.asSharedFlow() - init { - viewModelScope.launch { - inputPassword.collect { password -> - if (password.length == MAX_PASSWORD_LEN) { - checkPassword(inputPassword.value) - } - } - } - } + fun onNumberClicked(number: Int) { + val current = _uiState.value.input + if (current.length >= MAX_PASSWORD_LEN) return - fun onNumberClicked(number: String) { - if (inputPassword.value.length == MAX_PASSWORD_LEN) return - viewModelScope.launch { - inputPassword.emit(inputPassword.value + number) - } - } + val newInput = current + number.toString() + _uiState.update { it.copy(input = newInput, inputState = InputState.Typing) } - private fun checkPassword(inputPassword: String) { - viewModelScope.launch { - if (inputPassword == adminRepository.getAppPassword()) { - UiEvent.PasswordCorrect - } else { - clearPassword() - UiEvent.PasswordInCorrect - }.also { - _uiEvent.emit(it) - } + if (newInput.length == MAX_PASSWORD_LEN) { + checkPassword(newInput) } } - private fun clearPassword() { - viewModelScope.launch { - inputPassword.emit("") + fun onBackSpaceClicked() { + val current = _uiState.value.input + if (current.isEmpty()) return + _uiState.update { + it.copy( + input = current.dropLast(1), + inputState = if (current.length <= 1) InputState.Empty else InputState.Typing + ) } } - fun onBackSpaceClicked() { - if (inputPassword.value.isNotEmpty()) { - viewModelScope.launch { - inputPassword.emit(inputPassword.value.dropLast(1)) + private fun checkPassword(input: String) { + baseViewModelScope.launch { + if (input == adminRepository.getAppPassword()) { + _uiEvent.emit(UiEvent.PasswordCorrect) + } else { + _uiState.update { + it.copy( + inputState = InputState.Error(R.string.text_incorrect_password_error_message) + ) + } + _uiEvent.emit(UiEvent.PasswordInCorrect) + delay(ERROR_DISPLAY_MILLIS) + _uiState.update { UiState() } } } } + data class UiState( + val input: String = "", + val inputState: InputState = InputState.Empty, + ) + sealed interface UiEvent { data object PasswordCorrect : UiEvent data object PasswordInCorrect : UiEvent @@ -69,5 +77,6 @@ class CheckPasswordViewModel @Inject constructor( companion object { const val MAX_PASSWORD_LEN = 4 + private const val ERROR_DISPLAY_MILLIS = 500L } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordFragment.kt index 19e482f9..b50ab16b 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordFragment.kt @@ -1,35 +1,50 @@ package com.nextroom.nextroom.presentation.ui.password import android.os.Bundle +import android.view.LayoutInflater import android.view.View +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.R -import com.nextroom.nextroom.presentation.base.BaseFragment -import com.nextroom.nextroom.presentation.databinding.FragmentSetPasswordBinding +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment import com.nextroom.nextroom.presentation.extension.repeatOnStarted import com.nextroom.nextroom.presentation.extension.toast +import com.nextroom.nextroom.presentation.ui.password.compose.SetPasswordScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint -class SetPasswordFragment : BaseFragment(FragmentSetPasswordBinding::inflate) { - private val viewModel: SetPasswordViewModel by viewModels() +class SetPasswordFragment : ComposeBaseViewModelFragment() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override val screenName: String = "set_password" + override val viewModel: SetPasswordViewModel by viewModels() - initListener() - initSubscribe() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state by viewModel.uiState.collectAsState() + SetPasswordScreen( + state = state, + onKeyClick = viewModel::onNumberClicked, + onBackspaceClick = viewModel::onBackSpaceClicked, + onBackClick = { findNavController().navigateUp() }, + ) + } + } } - private fun initSubscribe() { + override fun initSubscribe() { viewLifecycleOwner.repeatOnStarted { - launch { - viewModel.uiState.collect { state -> - updateUi(state) - } - } launch { viewModel.uiEvent.collect { event -> when (event) { @@ -39,7 +54,6 @@ class SetPasswordFragment : BaseFragment(FragmentSet } SetPasswordViewModel.UiEvent.PasswordNotMatched -> { - binding.customCodeInput.setError() toast(getString(R.string.text_incorrect_password_error_message)) } } @@ -47,34 +61,4 @@ class SetPasswordFragment : BaseFragment(FragmentSet } } } - - private fun updateUi(state: SetPasswordViewModel.UiState) { - binding.customCodeInput.setCode(state.displayPassword) - when (state.step) { - SetPasswordViewModel.UiState.Step.PasswordSetting -> { - binding.tvHeader.text = getString(R.string.text_set_password) - binding.tvDescription.text = getString(R.string.text_set_password_description) - } - - SetPasswordViewModel.UiState.Step.PasswordConfirm -> { - binding.tvHeader.text = getString(R.string.text_confirm_password) - binding.tvDescription.text = getString(R.string.text_set_password_description_for_confirm) - } - } - } - - private fun initListener() { - binding.tvKey1.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_1)) } - binding.tvKey2.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_2)) } - binding.tvKey3.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_3)) } - binding.tvKey4.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_4)) } - binding.tvKey5.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_5)) } - binding.tvKey6.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_6)) } - binding.tvKey7.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_7)) } - binding.tvKey8.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_8)) } - binding.tvKey9.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_9)) } - binding.tvKey0.setOnClickListener { viewModel.onNumberClicked(getString(R.string.text_0)) } - binding.keyBackspace.setOnClickListener { viewModel.onBackSpaceClicked() } - binding.ivBack.setOnClickListener { findNavController().navigateUp() } - } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordViewModel.kt index 7095ca5e..5ac888a4 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/SetPasswordViewModel.kt @@ -1,96 +1,82 @@ package com.nextroom.nextroom.presentation.ui.password -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.nextroom.nextroom.domain.repository.AdminRepository +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.model.InputState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SetPasswordViewModel @Inject constructor( - val adminRepository: AdminRepository -) : ViewModel() { - private val firstPassword = MutableStateFlow("") - private val secondPassword = MutableStateFlow("") - private val step = MutableStateFlow(UiState.Step.PasswordSetting) + private val adminRepository: AdminRepository +) : NewBaseViewModel() { - val uiState = combine( - firstPassword, - secondPassword, - step, - ) { firstPassword, secondPassword, step -> - when (step) { - UiState.Step.PasswordSetting -> firstPassword - UiState.Step.PasswordConfirm -> secondPassword - }.let { password -> - UiState(displayPassword = password, step = step) - } - } - private val _uiEvent = MutableSharedFlow() + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) val uiEvent = _uiEvent.asSharedFlow() - init { - viewModelScope.launch { - firstPassword.collect { password -> - if (password.length == MAX_PASSWORD_LEN) { - step.emit(UiState.Step.PasswordConfirm) - } - } - } - viewModelScope.launch { - secondPassword.collect { password -> - if (password.length == MAX_PASSWORD_LEN) { - if (isPasswordMatch()) { - savePassword(password) - _uiEvent.emit(UiEvent.SettingPasswordFinished) - } else { - secondPassword.emit("") - _uiEvent.emit(UiEvent.PasswordNotMatched) - } - } - } - } - } + private var firstPassword: String = "" - fun onNumberClicked(number: String) { - viewModelScope.launch { - when (step.value) { - UiState.Step.PasswordSetting -> { - if (firstPassword.value.length == MAX_PASSWORD_LEN) return@launch - firstPassword.emit(firstPassword.value + number) - } + fun onNumberClicked(number: Int) { + val current = _uiState.value.displayPassword + if (current.length >= MAX_PASSWORD_LEN) return - UiState.Step.PasswordConfirm -> { - if (secondPassword.value.length == MAX_PASSWORD_LEN) return@launch - secondPassword.emit(secondPassword.value + number) - } - } + val newInput = current + number.toString() + _uiState.update { it.copy(displayPassword = newInput, inputState = InputState.Typing) } + + if (newInput.length == MAX_PASSWORD_LEN) { + onPasswordEntered(newInput) } } - private fun isPasswordMatch() = firstPassword.value == secondPassword.value - - private suspend fun savePassword(password: String) { - adminRepository.saveAppPassword(password) + fun onBackSpaceClicked() { + val current = _uiState.value.displayPassword + if (current.isEmpty()) return + _uiState.update { + it.copy( + displayPassword = current.dropLast(1), + inputState = if (current.length <= 1) InputState.Empty else InputState.Typing + ) + } } - fun onBackSpaceClicked() { - viewModelScope.launch { - when (step.value) { + private fun onPasswordEntered(password: String) { + baseViewModelScope.launch { + when (_uiState.value.step) { UiState.Step.PasswordSetting -> { - if (firstPassword.value.isNotEmpty()) { - firstPassword.emit(firstPassword.value.dropLast(1)) + firstPassword = password + _uiState.update { + UiState(step = UiState.Step.PasswordConfirm) } } UiState.Step.PasswordConfirm -> { - if (secondPassword.value.isNotEmpty()) { - secondPassword.emit(secondPassword.value.dropLast(1)) + if (password == firstPassword) { + adminRepository.saveAppPassword(password) + _uiEvent.emit(UiEvent.SettingPasswordFinished) + } else { + _uiState.update { + it.copy( + inputState = InputState.Error( + R.string.text_incorrect_password_error_message + ) + ) + } + _uiEvent.emit(UiEvent.PasswordNotMatched) + delay(ERROR_DISPLAY_MILLIS) + _uiState.update { + it.copy(displayPassword = "", inputState = InputState.Empty) + } } } } @@ -98,8 +84,9 @@ class SetPasswordViewModel @Inject constructor( } data class UiState( - val displayPassword: String, - val step: Step, + val displayPassword: String = "", + val step: Step = Step.PasswordSetting, + val inputState: InputState = InputState.Empty, ) { sealed interface Step { data object PasswordSetting : Step @@ -114,5 +101,6 @@ class SetPasswordViewModel @Inject constructor( companion object { const val MAX_PASSWORD_LEN = 4 + private const val ERROR_DISPLAY_MILLIS = 500L } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/CheckPasswordScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/CheckPasswordScreen.kt new file mode 100644 index 00000000..c9f986a4 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/CheckPasswordScreen.kt @@ -0,0 +1,141 @@ +package com.nextroom.nextroom.presentation.ui.password.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.NRTypo +import com.nextroom.nextroom.presentation.model.InputState +import com.nextroom.nextroom.presentation.ui.password.CheckPasswordViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.timer.compose.CodeInputSection + +@Composable +fun CheckPasswordScreen( + state: CheckPasswordViewModel.UiState, + onKeyClick: (Int) -> Unit, + onBackspaceClick: () -> Unit, + onBackClick: () -> Unit, + onBiometricClick: () -> Unit, + showBiometric: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + BackIcon(onClick = onBackClick) + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.65f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.text_input_password_header), + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.text_input_password_description), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.Gray01 + ) + Spacer(modifier = Modifier.height(24.dp)) + CodeInputSection( + code = state.input, + inputState = state.inputState + ) + Spacer(modifier = Modifier.weight(1f)) + } + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(28.dp)) + PinKeypad( + onKeyClick = onKeyClick, + onBackspaceClick = onBackspaceClick, + onBiometricClick = if (showBiometric) onBiometricClick else null, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(42.dp)) + } + } + } +} + +@Composable +private fun BackIcon(onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(64.dp) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() } + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_navigate_back_24), + contentDescription = stringResource(R.string.toolbar_navigate_back_description), + tint = NRColor.White + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 720) +@Composable +private fun CheckPasswordScreenPreview() { + CheckPasswordScreen( + state = CheckPasswordViewModel.UiState(input = "12", inputState = InputState.Typing), + onKeyClick = {}, + onBackspaceClick = {}, + onBackClick = {}, + onBiometricClick = {}, + showBiometric = true, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 720) +@Composable +private fun CheckPasswordScreenErrorPreview() { + CheckPasswordScreen( + state = CheckPasswordViewModel.UiState( + input = "", + inputState = InputState.Error(R.string.text_incorrect_password_error_message) + ), + onKeyClick = {}, + onBackspaceClick = {}, + onBackClick = {}, + onBiometricClick = {}, + showBiometric = false, + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/PinKeypad.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/PinKeypad.kt new file mode 100644 index 00000000..b3d12e2c --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/PinKeypad.kt @@ -0,0 +1,141 @@ +package com.nextroom.nextroom.presentation.ui.password.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.NRTypo + +private val KEY_HEIGHT = 64.dp + +@Composable +fun PinKeypad( + onKeyClick: (Int) -> Unit, + onBackspaceClick: () -> Unit, + modifier: Modifier = Modifier, + onBiometricClick: (() -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + KeypadRow(keys = listOf(1, 2, 3), onKeyClick = onKeyClick) + KeypadRow(keys = listOf(4, 5, 6), onKeyClick = onKeyClick) + KeypadRow(keys = listOf(7, 8, 9), onKeyClick = onKeyClick) + Row(modifier = Modifier.fillMaxWidth()) { + if (onBiometricClick != null) { + IconKey( + iconRes = R.drawable.ic_fingerprint, + onClick = onBiometricClick, + modifier = Modifier.weight(1f) + ) + } else { + Box( + modifier = Modifier + .weight(1f) + .height(KEY_HEIGHT) + ) + } + NumberKey( + key = 0, + onClick = { onKeyClick(0) }, + modifier = Modifier.weight(1f) + ) + IconKey( + iconRes = R.drawable.ic_backspace, + onClick = onBackspaceClick, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun KeypadRow( + keys: List, + onKeyClick: (Int) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth()) { + keys.forEach { key -> + NumberKey( + key = key, + onClick = { onKeyClick(key) }, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun NumberKey( + key: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .height(KEY_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = key.toString(), + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + } +} + +@Composable +private fun IconKey( + iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .height(KEY_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = NRColor.White + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun PinKeypadPreview() { + PinKeypad(onKeyClick = {}, onBackspaceClick = {}) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516) +@Composable +private fun PinKeypadWithBiometricPreview() { + PinKeypad(onKeyClick = {}, onBackspaceClick = {}, onBiometricClick = {}) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/SetPasswordScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/SetPasswordScreen.kt new file mode 100644 index 00000000..274854cf --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/password/compose/SetPasswordScreen.kt @@ -0,0 +1,148 @@ +package com.nextroom.nextroom.presentation.ui.password.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.NRTypo +import com.nextroom.nextroom.presentation.model.InputState +import com.nextroom.nextroom.presentation.ui.password.SetPasswordViewModel +import com.nextroom.nextroom.presentation.ui.tutorial.timer.compose.CodeInputSection + +@Composable +fun SetPasswordScreen( + state: SetPasswordViewModel.UiState, + onKeyClick: (Int) -> Unit, + onBackspaceClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val headerRes = when (state.step) { + SetPasswordViewModel.UiState.Step.PasswordSetting -> R.string.text_set_password + SetPasswordViewModel.UiState.Step.PasswordConfirm -> R.string.text_confirm_password + } + val descriptionRes = when (state.step) { + SetPasswordViewModel.UiState.Step.PasswordSetting -> R.string.text_set_password_description + SetPasswordViewModel.UiState.Step.PasswordConfirm -> R.string.text_set_password_description_for_confirm + } + + Box( + modifier = modifier + .fillMaxSize() + .background(NRColor.Dark01) + ) { + BackIcon(onClick = onBackClick) + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.65f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(headerRes), + style = NRTypo.Poppins.size24, + color = NRColor.White + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(descriptionRes), + style = NRTypo.Pretendard.size16Bold, + color = NRColor.Gray01 + ) + Spacer(modifier = Modifier.height(24.dp)) + CodeInputSection( + code = state.displayPassword, + inputState = state.inputState + ) + Spacer(modifier = Modifier.weight(1f)) + } + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(28.dp)) + PinKeypad( + onKeyClick = onKeyClick, + onBackspaceClick = onBackspaceClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(42.dp)) + } + } + } +} + +@Composable +private fun BackIcon(onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(64.dp) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(bounded = false) + ) { onClick() } + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_navigate_back_24), + contentDescription = stringResource(R.string.toolbar_navigate_back_description), + tint = NRColor.White + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 720) +@Composable +private fun SetPasswordSettingPreview() { + SetPasswordScreen( + state = SetPasswordViewModel.UiState( + displayPassword = "12", + step = SetPasswordViewModel.UiState.Step.PasswordSetting, + inputState = InputState.Typing, + ), + onKeyClick = {}, + onBackspaceClick = {}, + onBackClick = {}, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFF151516, heightDp = 720) +@Composable +private fun SetPasswordConfirmPreview() { + SetPasswordScreen( + state = SetPasswordViewModel.UiState( + displayPassword = "", + step = SetPasswordViewModel.UiState.Step.PasswordConfirm, + inputState = InputState.Error(R.string.text_incorrect_password_error_message), + ), + onKeyClick = {}, + onBackspaceClick = {}, + onBackClick = {}, + ) +} diff --git a/presentation/src/main/res/layout/fragment_check_password.xml b/presentation/src/main/res/layout/fragment_check_password.xml deleted file mode 100644 index b9c3bc47..00000000 --- a/presentation/src/main/res/layout/fragment_check_password.xml +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/layout/fragment_set_password.xml b/presentation/src/main/res/layout/fragment_set_password.xml deleted file mode 100644 index bb94b54a..00000000 --- a/presentation/src/main/res/layout/fragment_set_password.xml +++ /dev/null @@ -1,187 +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 ceb23818..1da82b48 100644 --- a/presentation/src/main/res/navigation/nav_graph.xml +++ b/presentation/src/main/res/navigation/nav_graph.xml @@ -77,8 +77,7 @@ + android:name="com.nextroom.nextroom.presentation.ui.password.CheckPasswordFragment"> + android:name="com.nextroom.nextroom.presentation.ui.password.SetPasswordFragment" />