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" }