diff --git a/build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt b/build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt index 37592f4..7948cbd 100644 --- a/build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt +++ b/build-logic/src/main/java/com/puzzle/build/logic/configure/AndroidComposes.kt @@ -29,6 +29,7 @@ internal fun Project.configureAndroidCompose() { add("implementation", libs.findLibrary("androidx.compose.material3").get()) add("implementation", libs.findLibrary("androidx.compose.ui").get()) add("implementation", libs.findLibrary("androidx.compose.ui.tooling.preview").get()) + add("implementation", libs.findLibrary("androidx.compose.foundation").get()) add("debugImplementation", libs.findLibrary("androidx.compose.ui.tooling").get()) } } diff --git a/core/data/src/main/java/com/puzzle/data/di/DataModule.kt b/core/data/src/main/java/com/puzzle/data/di/DataModule.kt index bba9932..d98c3a5 100644 --- a/core/data/src/main/java/com/puzzle/data/di/DataModule.kt +++ b/core/data/src/main/java/com/puzzle/data/di/DataModule.kt @@ -2,8 +2,10 @@ package com.puzzle.data.di import com.puzzle.data.repository.AuthRepositoryImpl import com.puzzle.data.repository.TermsRepositoryImpl +import com.puzzle.data.repository.VerificationCodeRepositoryImpl import com.puzzle.domain.repository.AuthRepository import com.puzzle.domain.repository.TermsRepository +import com.puzzle.domain.repository.VerificationCodeRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -26,4 +28,10 @@ abstract class DataModule { abstract fun bindsTermsRepository( termsRepositoryImpl: TermsRepositoryImpl, ): TermsRepository + + @Binds + @Singleton + abstract fun bindsVerificationCodeRepository( + verificationCodeRepositoryImpl: VerificationCodeRepositoryImpl, + ): VerificationCodeRepository } diff --git a/core/data/src/main/java/com/puzzle/data/repository/VerificationCodeRepositoryImpl.kt b/core/data/src/main/java/com/puzzle/data/repository/VerificationCodeRepositoryImpl.kt new file mode 100644 index 0000000..d47e033 --- /dev/null +++ b/core/data/src/main/java/com/puzzle/data/repository/VerificationCodeRepositoryImpl.kt @@ -0,0 +1,14 @@ +package com.puzzle.data.repository + +import com.puzzle.domain.repository.VerificationCodeRepository +import javax.inject.Inject + +class VerificationCodeRepositoryImpl @Inject constructor() : VerificationCodeRepository { + override suspend fun requestVerificationCode(phoneNumber: String): Boolean { + return true + } + + override suspend fun verify(code: String): Boolean { + return true + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt index 18eb188..4e9e8d7 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt @@ -10,6 +10,7 @@ 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.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -40,7 +41,9 @@ fun PieceSolidButton( disabledContainerColor = PieceTheme.colors.light1, disabledContentColor = PieceTheme.colors.white, ), - modifier = modifier.height(52.dp), + modifier = modifier + .height(52.dp) + .widthIn(min = 100.dp), ) { Text( text = label, @@ -67,7 +70,9 @@ fun PieceOutlinedButton( disabledContainerColor = PieceTheme.colors.light1, disabledContentColor = PieceTheme.colors.primaryDefault, ), - modifier = modifier.height(52.dp), + modifier = modifier + .height(52.dp) + .widthIn(min = 100.dp), ) { Text( text = label, @@ -93,7 +98,9 @@ fun PieceSubButton( disabledContainerColor = PieceTheme.colors.light3, disabledContentColor = PieceTheme.colors.dark2, ), - modifier = modifier.height(52.dp), + modifier = modifier + .height(52.dp) + .widthIn(min = 100.dp), ) { Text( text = label, @@ -120,7 +127,9 @@ fun PieceIconButton( disabledContainerColor = PieceTheme.colors.light1, disabledContentColor = PieceTheme.colors.white, ), - modifier = modifier.height(52.dp), + modifier = modifier + .height(52.dp) + .widthIn(min = 100.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/core/designsystem/src/main/res/drawable/ic_google_login.xml b/core/designsystem/src/main/res/drawable/ic_google_login.xml new file mode 100644 index 0000000..110c82d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_google_login.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_kakao_login.xml b/core/designsystem/src/main/res/drawable/ic_kakao_login.xml new file mode 100644 index 0000000..06413ea --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_kakao_login.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index b350db6..6e915ae 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -11,6 +11,20 @@ 나와 같은 가치관 매칭 수락하기 + + 신뢰도 높은 매칭과 안전한 커뮤니티를 위해\n휴대폰 번호로 인증해 주세요. + 확인 + 다음 + 어떤 경우에도 타인에게 공유하지 마세요 + 전화번호 인증을 완료했어요 + 올바른 인증번호가 아니에요 + 유효시간이 지났어요! ‘인증번호 재전송’을 눌러주세요 + 인증번호 + 인증번호 재전송 + 인증번호 받기 + 휴대폰 번호 + 올바른 전화번호가 아니에요 + 가치관 Talk diff --git a/core/domain/src/main/java/com/puzzle/domain/repository/VerificationCodeRepository.kt b/core/domain/src/main/java/com/puzzle/domain/repository/VerificationCodeRepository.kt new file mode 100644 index 0000000..5687e01 --- /dev/null +++ b/core/domain/src/main/java/com/puzzle/domain/repository/VerificationCodeRepository.kt @@ -0,0 +1,6 @@ +package com.puzzle.domain.repository + +interface VerificationCodeRepository { + suspend fun requestVerificationCode(phoneNumber: String): Boolean + suspend fun verify(code: String): Boolean +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/login/LoginScreen.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/login/LoginScreen.kt index faa84c9..891c158 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/login/LoginScreen.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/login/LoginScreen.kt @@ -1,21 +1,42 @@ package com.puzzle.auth.graph.login import android.util.Log +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.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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.kakao.sdk.user.UserApiClient import com.puzzle.auth.graph.login.contract.LoginIntent.Navigate import com.puzzle.auth.graph.login.contract.LoginState +import com.puzzle.designsystem.R +import com.puzzle.designsystem.component.PieceSubCloseTopBar import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.navigation.AuthGraph import com.puzzle.navigation.AuthGraphDest @@ -48,29 +69,138 @@ fun LoginScreen( state: LoginState, loginKakao: () -> Unit, navigate: (NavigationEvent) -> Unit, + modifier: Modifier = Modifier, ) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() + .background(PieceTheme.colors.white) + .padding(horizontal = 20.dp) .clickable { navigate( NavigationEvent.NavigateTo( - route = AuthGraphDest.RegistrationRoute, + route = AuthGraphDest.VerificationRoute, popUpTo = AuthGraph, ) ) }, ) { + PieceSubCloseTopBar( + title = "", + onCloseClick = { }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + ) + + Spacer(modifier = Modifier.height(20.dp)) + Text( - text = "카카오 로그인", - fontSize = 30.sp, - modifier = Modifier.clickable { loginKakao() }, + text = buildAnnotatedString { + withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { + append("Piece") + } + + append("에서 마음이 통하는\n이상형을 만나보세요") + }, + style = PieceTheme.typography.headingLSB, + color = PieceTheme.colors.black, + modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.height(12.dp)) + Text( - text = "AuthRoute", - fontSize = 30.sp, + text = "서로의 빈 곳을 채우며 맞물리는 퍼즐처럼.\n서로의 가치관과 마음이 연결되는 순간을 만들어갑니다.", + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.dark3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(70.dp)) + + Image( + painter = painterResource(R.drawable.ic_puzzle1), + contentDescription = null, + modifier = Modifier + .size(240.dp) + .align(Alignment.CenterHorizontally), ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = loginKakao, + enabled = true, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFE812), + contentColor = PieceTheme.colors.white, + disabledContainerColor = PieceTheme.colors.light1, + disabledContentColor = PieceTheme.colors.white, + ), + modifier = Modifier + .height(52.dp) + .fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + painter = painterResource(R.drawable.ic_kakao_login), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + + Text( + text = "카카오로 시작하기", + style = PieceTheme.typography.bodyMSB, + color = PieceTheme.colors.black, + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Button( + onClick = loginKakao, + enabled = true, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PieceTheme.colors.white, + contentColor = PieceTheme.colors.white, + disabledContainerColor = PieceTheme.colors.light1, + disabledContentColor = PieceTheme.colors.white, + ), + modifier = Modifier + .height(52.dp) + .fillMaxWidth() + .border( + width = 1.dp, + color = PieceTheme.colors.light1, + shape = RoundedCornerShape(8.dp) + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + painter = painterResource(R.drawable.ic_google_login), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + + Text( + text = "구글로 시작하기", + style = PieceTheme.typography.bodyMSB, + color = PieceTheme.colors.black, + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) } } diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationScreen.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationScreen.kt index 2c36530..1932ea9 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationScreen.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationScreen.kt @@ -1,10 +1,47 @@ package com.puzzle.auth.graph.verification +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel -import com.puzzle.auth.graph.verification.contract.VerficationState +import com.puzzle.auth.graph.verification.contract.VerificationIntent +import com.puzzle.auth.graph.verification.contract.VerificationSideEffect +import com.puzzle.auth.graph.verification.contract.VerificationState +import com.puzzle.auth.graph.verification.contract.VerificationState.VerificationCodeStatus +import com.puzzle.designsystem.R +import com.puzzle.designsystem.component.PieceSolidButton +import com.puzzle.designsystem.component.PieceSubCloseTopBar +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.navigation.AuthGraph +import com.puzzle.navigation.AuthGraphDest +import com.puzzle.navigation.NavigationEvent @Composable internal fun VerificationRoute( @@ -14,12 +51,282 @@ internal fun VerificationRoute( VerificationScreen( state = state, + navigate = { + viewModel.onSideEffect(VerificationSideEffect.Navigate(it)) + }, + onRequestVerificationCodeClick = { phoneNumber -> + viewModel.onIntent(VerificationIntent.OnRequestVerificationCodeClick(phoneNumber)) + }, + onVerifyClick = { code -> + viewModel.onIntent(VerificationIntent.OnVerifyClick(code)) + }, + onNextClick = { + viewModel.onIntent(VerificationIntent.OnNextClick) + } ) } @Composable private fun VerificationScreen( - state: VerficationState, + state: VerificationState, + onRequestVerificationCodeClick: (String) -> Unit, + onVerifyClick: (String) -> Unit, + onNextClick: () -> Unit, + navigate: (NavigationEvent) -> Unit, + modifier: Modifier = Modifier, ) { + Column( + modifier = modifier + .fillMaxSize() + .background(PieceTheme.colors.white) + .padding(horizontal = 20.dp) + .clickable { + navigate( + NavigationEvent.NavigateTo( + route = AuthGraphDest.RegistrationRoute, + popUpTo = AuthGraph, + ) + ) + }, + ) { + PieceSubCloseTopBar( + title = "", + onCloseClick = { + navigate(NavigationEvent.NavigateUp) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + ) + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { + append("휴대폰 번호") + } + append("로\n인증을 진행해 주세요") + }, + style = PieceTheme.typography.headingLSB, + color = PieceTheme.colors.black, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.verification_subtitle), + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.dark3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(70.dp)) + + PhoneNumberBody( + isValidPhoneNumber = state.isValidPhoneNumber, + hasStarted = state.hasStarted, + onRequestVerificationCodeClick = onRequestVerificationCodeClick, + ) + + if (state.hasStarted) { + Spacer(modifier = Modifier.height(32.dp)) + + VerificationCodeBody( + remainingTimeInSec = state.remainingTimeInSec, + verificationCodeStatus = state.verificationCodeStatus, + onVerifyClick = onVerifyClick, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + PieceSolidButton( + label = stringResource(R.string.verification_submit), + onClick = { + onNextClick() + }, + enabled = state.isVerified, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(10.dp)) + } +} + +@Composable +private fun VerificationCodeBody( + remainingTimeInSec: Int, + verificationCodeStatus: VerificationCodeStatus, + onVerifyClick: (String) -> Unit, +) { + var verificationCode by rememberSaveable { mutableStateOf("") } + + val (verificationCodeStatusMessage, verificationCodeStatusColor) = + when (verificationCodeStatus) { + VerificationCodeStatus.DO_NOT_SHARE -> + stringResource(R.string.verification_do_not_share) to PieceTheme.colors.dark3 + + VerificationCodeStatus.VERIFIED -> + stringResource(R.string.verification_verified) to PieceTheme.colors.primaryDefault + + VerificationCodeStatus.INVALID -> + stringResource(R.string.verification_invalid_code) to PieceTheme.colors.subDefault + + VerificationCodeStatus.TIME_EXPIRED -> + stringResource(R.string.verification_time_expired) to PieceTheme.colors.subDefault + } + + val isVerifyButtonEnabled = + verificationCodeStatus == VerificationCodeStatus.DO_NOT_SHARE || + verificationCodeStatus == VerificationCodeStatus.INVALID + + Text( + text = stringResource(R.string.verification_verifiaction_code), + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.dark3, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = verificationCode, + onValueChange = { verificationCode = it }, + textStyle = PieceTheme.typography.bodyMM, + decorationBox = { innerTextField -> + Box { + innerTextField() + + Text( + text = formatTime(remainingTimeInSec), + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.primaryDefault, + modifier = Modifier + .align(Alignment.CenterEnd), + ) + } + }, + modifier = Modifier + .height(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(PieceTheme.colors.light3) + .padding( + horizontal = 16.dp, + vertical = 14.dp, + ) + .weight(1f), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + PieceSolidButton( + label = stringResource(R.string.verification_submit), + onClick = { + onVerifyClick(verificationCode) + }, + enabled = isVerifyButtonEnabled, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = verificationCodeStatusMessage, + style = PieceTheme.typography.bodySM, + color = verificationCodeStatusColor, + ) +} + +@Composable +private fun PhoneNumberBody( + hasStarted: Boolean, + isValidPhoneNumber: Boolean, + onRequestVerificationCodeClick: (String) -> Unit +) { + var phoneNumber by rememberSaveable { mutableStateOf("") } + + val requestButtonLabel = + if (hasStarted) stringResource(R.string.verification_resend) else stringResource(R.string.verification_request) + + Text( + text = stringResource(R.string.verification_phone_number), + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.dark3, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = phoneNumber, + onValueChange = { phoneNumber = it }, + textStyle = PieceTheme.typography.bodyMM, + modifier = Modifier + .height(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(PieceTheme.colors.light3) + .padding( + horizontal = 16.dp, + vertical = 14.dp, + ) + .weight(1f), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + PieceSolidButton( + label = requestButtonLabel, + onClick = { + onRequestVerificationCodeClick(phoneNumber) + }, + enabled = phoneNumber.isNotEmpty(), + ) + } + + if (!isValidPhoneNumber) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.verification_invalid_phone_number), + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.subDefault, + ) + } +} + +/** + * 초 단위 [seconds]를 "mm:ss" 형태 문자열로 변환하는 함수 + */ +@SuppressLint("DefaultLocale") +fun formatTime(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return String.format("%02d:%02d", minutes, secs) +} + +@Preview +@Composable +fun PreviewVerificationScreen() { + PieceTheme { + VerificationScreen( + state = VerificationState( + hasStarted = true, + remainingTimeInSec = 299, + isVerified = true, + verificationCodeStatus = VerificationCodeStatus.DO_NOT_SHARE, + ), + navigate = {}, + onRequestVerificationCodeClick = {}, + onVerifyClick = {}, + onNextClick = {}, + ) + } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationViewModel.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationViewModel.kt index 1490b86..fe2eb49 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationViewModel.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/VerificationViewModel.kt @@ -4,21 +4,148 @@ import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.hilt.AssistedViewModelFactory import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory -import com.puzzle.auth.graph.verification.contract.VerficationState +import com.puzzle.auth.graph.verification.contract.VerificationIntent +import com.puzzle.auth.graph.verification.contract.VerificationSideEffect +import com.puzzle.auth.graph.verification.contract.VerificationState +import com.puzzle.domain.repository.VerificationCodeRepository +import com.puzzle.navigation.AuthGraph +import com.puzzle.navigation.AuthGraphDest +import com.puzzle.navigation.NavigationEvent import com.puzzle.navigation.NavigationHelper import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch class VerificationViewModel @AssistedInject constructor( - @Assisted initialState: VerficationState, + @Assisted initialState: VerificationState, private val navigationHelper: NavigationHelper, -) : MavericksViewModel(initialState) { + private val verificationCodeRepository: VerificationCodeRepository, +) : MavericksViewModel(initialState) { @AssistedFactory - interface Factory : AssistedViewModelFactory { - override fun create(state: VerficationState): VerificationViewModel + interface Factory : AssistedViewModelFactory { + override fun create(state: VerificationState): VerificationViewModel + } + + private val intents = Channel(BUFFERED) + private val sideEffects = Channel(BUFFERED) + private var timerJob: Job? = null + + init { + intents.receiveAsFlow() + .onEach(::processIntent) + .launchIn(viewModelScope) + + sideEffects.receiveAsFlow() + .onEach(::handleSideEffect) + .launchIn(viewModelScope) + } + + internal fun onIntent(intent: VerificationIntent) = viewModelScope.launch { + intents.send(intent) + } + + internal fun onSideEffect(sideEffect: VerificationSideEffect) = viewModelScope.launch { + sideEffects.send(sideEffect) + } + + private fun processIntent(intent: VerificationIntent) { + when (intent) { + is VerificationIntent.OnRequestVerificationCodeClick -> requestVerificationCode(intent.phoneNumber) + is VerificationIntent.OnVerifyClick -> verify(intent.code) + VerificationIntent.OnNextClick -> moveToNextPage() + } + } + + private fun handleSideEffect(sideEffect: VerificationSideEffect) { + when (sideEffect) { + is VerificationSideEffect.Navigate -> navigationHelper.navigate(sideEffect.navigationEvent) + } + } + + private fun moveToNextPage() { + navigationHelper.navigate( + NavigationEvent.NavigateTo( + route = AuthGraphDest.RegistrationRoute, + popUpTo = AuthGraph, + ) + ) + } + + private fun requestVerificationCode(phoneNumber: String) { + viewModelScope.launch { + val result = verificationCodeRepository.requestVerificationCode(phoneNumber) + + setState { + copy( + isValidPhoneNumber = result + ) + } + + if (!result) return@launch + + startCodeExpiryTimer(5) + } + } + + private fun verify(code: String) { + viewModelScope.launch { + val result = verificationCodeRepository.verify(code) + setState { + copy( + isVerified = result, + remainingTimeInSec = 0, + verificationCodeStatus = if (result) { + timerJob?.cancel() + VerificationState.VerificationCodeStatus.VERIFIED + } else { + VerificationState.VerificationCodeStatus.INVALID + }, + ) + } + } + } + + private fun startCodeExpiryTimer(durationInSec: Int = 300) { + timerJob?.cancel() + + setState { + copy( + hasStarted = true, + remainingTimeInSec = durationInSec, + verificationCodeStatus = VerificationState.VerificationCodeStatus.DO_NOT_SHARE, + ) + } + + timerJob = viewModelScope.launch { + while (true) { + delay(1000L) + withState { currentState -> + if (currentState.remainingTimeInSec <= 0) { + setState { + copy( + remainingTimeInSec = 0, + verificationCodeStatus = VerificationState.VerificationCodeStatus.TIME_EXPIRED, + ) + } + return@withState + } + + setState { + copy(remainingTimeInSec = currentState.remainingTimeInSec - 1) + } + } + } + } } companion object : - MavericksViewModelFactory by hiltMavericksViewModelFactory() + MavericksViewModelFactory by hiltMavericksViewModelFactory() } diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerficationState.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerficationState.kt deleted file mode 100644 index fe486ee..0000000 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerficationState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.puzzle.auth.graph.verification.contract - -import com.airbnb.mvrx.MavericksState - -data class VerficationState( - val a: Boolean = false, -) : MavericksState \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationIntent.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationIntent.kt index fbc0a7c..2eb1ac8 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationIntent.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationIntent.kt @@ -1,3 +1,7 @@ package com.puzzle.auth.graph.verification.contract -sealed class VerificationIntent \ No newline at end of file +sealed class VerificationIntent { + data class OnRequestVerificationCodeClick(val phoneNumber: String) : VerificationIntent() + data class OnVerifyClick(val code: String) : VerificationIntent() + data object OnNextClick : VerificationIntent() +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationSideEffect.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationSideEffect.kt index 226c544..e7292ef 100644 --- a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationSideEffect.kt +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationSideEffect.kt @@ -1,3 +1,7 @@ package com.puzzle.auth.graph.verification.contract -sealed class VerificationSideEffect \ No newline at end of file +import com.puzzle.navigation.NavigationEvent + +sealed class VerificationSideEffect { + data class Navigate(val navigationEvent: NavigationEvent) : VerificationSideEffect() +} diff --git a/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationState.kt b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationState.kt new file mode 100644 index 0000000..437272b --- /dev/null +++ b/feature/auth/src/main/java/com/puzzle/auth/graph/verification/contract/VerificationState.kt @@ -0,0 +1,19 @@ +package com.puzzle.auth.graph.verification.contract + +import com.airbnb.mvrx.MavericksState + +data class VerificationState( + val isValidPhoneNumber: Boolean = true, + val hasStarted: Boolean = false, + val remainingTimeInSec: Int = 0, + val verificationCodeStatus: VerificationCodeStatus = VerificationCodeStatus.DO_NOT_SHARE, + val isVerified: Boolean = false, +) : MavericksState { + + enum class VerificationCodeStatus { + DO_NOT_SHARE, // "어떤 경우에도 타인에게 공유하지 마세요" + VERIFIED, // "전화번호 인증을 완료했어요" + INVALID, // "올바른 인증번호가 아니에요" + TIME_EXPIRED, // "유효시간이 지났어요! ‘인증번호 재전송’을 눌러주세요" + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4cd657..edc366c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,8 @@ androidxRoom = "2.6.1" androidxComposeBom = "2024.12.01" # https://developer.android.com/jetpack/androidx/releases/navigation androidxComposeNavigation = "2.8.4" +# https://developer.android.com/jetpack/androidx/releases/compose-foundation +androidxComposeFoundation = "1.7.6" ## Material material = "1.12.0" @@ -115,6 +117,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "androidxComposeFoundation" } androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }