From ad7170dd2175f79e8ef7bd0d1cbb0997a6f17347 Mon Sep 17 00:00:00 2001 From: zziwonCHOI Date: Mon, 25 Aug 2025 03:46:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20OPS-34=20=EA=B0=9C=EC=A2=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/remote/PiggyBankApiService.kt | 21 +- .../request/CreatePiggyBankRequestDto.kt | 7 + .../piggybank/request/SendSMSRequestDto.kt | 6 + .../piggybank/request/VerifySMSRequestDto.kt | 7 + .../response/CreatePiggyBankResponseDto.kt | 7 + .../response/VerifySMSResponseDto.kt | 5 + .../repository/PiggyBankRepositoryImpl.kt | 60 +++++ .../domain/entity/piggybank/OpenAccount.kt | 27 ++- .../domain/repository/PiggyBankRepository.kt | 5 + .../piggybank/CreatePiggyBankUseCase.kt | 16 ++ .../usecase/piggybank/PiggyBankUseCases.kt | 5 +- .../usecase/piggybank/SendSMSUseCase.kt | 15 ++ .../usecase/piggybank/VerifySMSUseCase.kt | 13 ++ .../navigation/NavigationGraph.kt | 7 +- .../ui/piggybank/OpenAccountScreen.kt | 172 +++++++++----- .../ui/piggybank/OpenAccountState.kt | 2 - .../ui/piggybank/OpenAccountViewModel.kt | 216 +++++++++++++++++- 17 files changed, 514 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/CreatePiggyBankRequestDto.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/SendSMSRequestDto.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerifySMSRequestDto.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/CreatePiggyBankResponseDto.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerifySMSResponseDto.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/CreatePiggyBankUseCase.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/SendSMSUseCase.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/VerifySMSUseCase.kt diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt index a307506..cdb77cf 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt @@ -2,11 +2,16 @@ package com.ssafy.tiggle.data.datasource.remote import com.ssafy.tiggle.data.model.BaseResponse import com.ssafy.tiggle.data.model.EmptyResponse +import com.ssafy.tiggle.data.model.piggybank.request.CreatePiggyBankRequestDto import com.ssafy.tiggle.data.model.piggybank.request.PrimaryAccountRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.SendSMSRequestDto import com.ssafy.tiggle.data.model.piggybank.request.VerificationCheckRequestDto import com.ssafy.tiggle.data.model.piggybank.request.VerificationRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerifySMSRequestDto import com.ssafy.tiggle.data.model.piggybank.response.AccountHolderResponseDto +import com.ssafy.tiggle.data.model.piggybank.response.CreatePiggyBankResponseDto import com.ssafy.tiggle.data.model.piggybank.response.VerificationCheckResponseDto +import com.ssafy.tiggle.data.model.piggybank.response.VerifySMSResponseDto import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -33,5 +38,19 @@ interface PiggyBankApiService { suspend fun registerPrimaryAccount( @Body body: PrimaryAccountRequestDto ): BaseResponse + + @POST("piggybank") + suspend fun createPiggyBank( + @Body body: CreatePiggyBankRequestDto + ): BaseResponse + + @POST("auth/sms/send") + suspend fun sendSMS( + @Body body: SendSMSRequestDto + ): BaseResponse + + @POST("auth/sms/verify") + suspend fun verifySMS( + @Body body: VerifySMSRequestDto + ): BaseResponse } -//0888315782686732 \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/CreatePiggyBankRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/CreatePiggyBankRequestDto.kt new file mode 100644 index 0000000..100d417 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/CreatePiggyBankRequestDto.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class CreatePiggyBankRequestDto( + val name: String = "", + val targetAmount: Long = 0L, + val esgCategoryId: Int = 1 +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/SendSMSRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/SendSMSRequestDto.kt new file mode 100644 index 0000000..10a72dd --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/SendSMSRequestDto.kt @@ -0,0 +1,6 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class SendSMSRequestDto( + val phone: String = "", + val purpose: String = "", +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerifySMSRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerifySMSRequestDto.kt new file mode 100644 index 0000000..e4bff56 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerifySMSRequestDto.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class VerifySMSRequestDto( + val phone: String = "", + val code: String = "", + val purpose: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/CreatePiggyBankResponseDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/CreatePiggyBankResponseDto.kt new file mode 100644 index 0000000..9b3ec98 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/CreatePiggyBankResponseDto.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.data.model.piggybank.response + +data class CreatePiggyBankResponseDto( + val name: String = "", + val currentAmount: Long = 0L, + val lastWeekSavedAmount: Int = 0 +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerifySMSResponseDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerifySMSResponseDto.kt new file mode 100644 index 0000000..a43b645 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerifySMSResponseDto.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.data.model.piggybank.response + +data class VerifySMSResponseDto( + val match: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt index 4006bc8..850ac65 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt @@ -1,9 +1,13 @@ package com.ssafy.tiggle.data.repository import com.ssafy.tiggle.data.datasource.remote.PiggyBankApiService +import com.ssafy.tiggle.data.model.piggybank.request.CreatePiggyBankRequestDto import com.ssafy.tiggle.data.model.piggybank.request.PrimaryAccountRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.SendSMSRequestDto import com.ssafy.tiggle.data.model.piggybank.request.VerificationCheckRequestDto import com.ssafy.tiggle.data.model.piggybank.request.VerificationRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerifySMSRequestDto +import com.ssafy.tiggle.data.model.piggybank.response.VerifySMSResponseDto import com.ssafy.tiggle.data.model.piggybank.response.toDomain import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder import com.ssafy.tiggle.domain.repository.PiggyBankRepository @@ -87,4 +91,60 @@ class PiggyBankRepositoryImpl @Inject constructor( } } + override suspend fun createPiggyBank( + name: String, + targetAmount: Long, + esgCategoryId: Int + ): Result { + return try { + val res = piggyBankApiService.createPiggyBank( + CreatePiggyBankRequestDto(name, targetAmount, esgCategoryId) + ) + if (res.result && res.data != null) { + Result.success(Unit) + } else { + Result.failure(Exception(res.message ?: "계좌 정보 보내기에 실패했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun sendSMS( + phone: String, + purpose: String + ): Result { + return try { + val res = piggyBankApiService.sendSMS( + SendSMSRequestDto(phone, purpose) + ) + if (res.result && res.data != null) { + Result.success(Unit) + } else { + Result.failure(Exception(res.message ?: "인증번호 발송 요청을 보내기에 실패했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun verifySMS( + phone: String, + code: String, + purpose: String + ): Result { + return try { + val res = piggyBankApiService.verifySMS( + VerifySMSRequestDto(phone, code, purpose) + ) + if (res.result && res.data != null) { + Result.success(res.data) + } else { + Result.failure(Exception(res.message ?: "인증 코드 검증에 실패했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt index f032f98..6aa4d2f 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt @@ -2,13 +2,16 @@ package com.ssafy.tiggle.domain.entity.piggybank data class OpenAccount( - val targetDonationAmount: Int = 0, + val targetDonationAmount: Long = 0L, val piggyBankName: String = "", - val certificateCode: Int = 0, + val certificateCode: String = "", + val phoneNum: String = "", + val attemptsLeft: Int = 3, val amountError: String? = null, val piggyBankNameError: String? = null, val codeError: String? = null, + val phoneNumError: String? = null ) { /** * 목표 금액 유효성 검사 @@ -45,6 +48,15 @@ data class OpenAccount( } } + fun validatePhoneNum(input: String): String? { + return when { + input.isBlank() -> "휴대폰 번호를 입력해주세요." + !input.matches(Regex("^[0-9]+$")) -> "숫자만 입력할 수 있습니다." + input.length != 11 -> "휴대폰 번호는 11자리여야 합니다." + else -> null + } + } + /** * 전체 유효성 검사를 수행하고 에러가 포함된 새로운 인스턴스 반환 */ @@ -52,7 +64,8 @@ data class OpenAccount( return this.copy( amountError = validateTargetDonationAmount(targetDonationAmount.toString()), piggyBankNameError = validatePiggyBankName(), - codeError = validateCode(certificateCode.toString()) + codeError = validateCode(certificateCode.toString()), + phoneNumError = validatePhoneNum(phoneNum) ) } @@ -69,7 +82,10 @@ data class OpenAccount( ValidationField.ACCOUNTNAME -> copy(piggyBankNameError = validatePiggyBankName()) ValidationField.CODE -> - copy(codeError = validateCode(certificateCode.toString())) + copy(codeError = validateCode(certificateCode)) + + ValidationField.PHONE -> + copy(phoneNumError = validatePhoneNum(phoneNum)) } } @@ -78,5 +94,6 @@ data class OpenAccount( enum class ValidationField { AMOUNT, ACCOUNTNAME, - CODE + CODE, + PHONE } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt index ae0f939..fe33336 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt @@ -1,5 +1,6 @@ package com.ssafy.tiggle.domain.repository +import com.ssafy.tiggle.data.model.piggybank.response.VerifySMSResponseDto import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder interface PiggyBankRepository { @@ -10,4 +11,8 @@ interface PiggyBankRepository { accountNo: String, verificationToken: String ): Result + + suspend fun createPiggyBank(name: String, targetAmount: Long, esgCategoryId: Int): Result + suspend fun sendSMS(phone: String, purpose: String): Result + suspend fun verifySMS(phone: String, code: String, purpose: String): Result } \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/CreatePiggyBankUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/CreatePiggyBankUseCase.kt new file mode 100644 index 0000000..af398e9 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/CreatePiggyBankUseCase.kt @@ -0,0 +1,16 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class CreatePiggyBankUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke( + name: String, + targetAmount: Long, + esgCategoryId: Int + ): Result { + return repository.createPiggyBank(name, targetAmount, esgCategoryId) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt index 0da10e8..aa9bc46 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt @@ -6,5 +6,8 @@ data class PiggyBankUseCases @Inject constructor( val getAccountHolderUseCase: GetAccountHolderUseCase, val requestOneWonVerificationUseCase: RequestOneWonVerificationUseCase, val requestOneWonCheckVerificationUseCase: RequestOneWonCheckVerificationUseCase, - val registerPrimaryAccountUseCase: RegisterPrimaryAccountUseCase + val registerPrimaryAccountUseCase: RegisterPrimaryAccountUseCase, + val createPiggyBankUseCase: CreatePiggyBankUseCase, + val sendSMSUseCase: SendSMSUseCase, + val verifySMSUseCase: VerifySMSUseCase ) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/SendSMSUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/SendSMSUseCase.kt new file mode 100644 index 0000000..7fafccc --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/SendSMSUseCase.kt @@ -0,0 +1,15 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class SendSMSUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke( + phone: String, + purpose: String + ): Result { + return repository.sendSMS(phone, purpose) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/VerifySMSUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/VerifySMSUseCase.kt new file mode 100644 index 0000000..de30cf5 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/VerifySMSUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.data.model.piggybank.response.VerifySMSResponseDto +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class VerifySMSUseCase @Inject constructor( + val repository: PiggyBankRepository +) { + suspend operator fun invoke(phone: String, code: String, purpose: String): Result { + return repository.verifySMS(phone, code, purpose) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt index 39708c6..59d035f 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt @@ -89,7 +89,12 @@ fun NavigationGraph() { } is Screen.OpenAccount -> NavEntry(key) { - OpenAccountScreen() + OpenAccountScreen( + onBackClick = { navBackStack.removeLastOrNull() }, + onFinish = { + navBackStack.removeLastOrNull() + } + ) } is Screen.RegisterAccount -> NavEntry(key) { diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt index 7cc93d5..b44ee3d 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.ssafy.tiggle.R import com.ssafy.tiggle.domain.entity.piggybank.OpenAccount import com.ssafy.tiggle.presentation.ui.components.TiggleAllAgreeCheckboxItem @@ -46,6 +46,7 @@ import com.ssafy.tiggle.presentation.ui.components.TiggleButton import com.ssafy.tiggle.presentation.ui.components.TiggleButtonVariant import com.ssafy.tiggle.presentation.ui.components.TiggleCheckboxItem import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +import com.ssafy.tiggle.presentation.ui.components.TiggleTextField import com.ssafy.tiggle.presentation.ui.theme.AppTypography import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue import com.ssafy.tiggle.presentation.ui.theme.TiggleGray @@ -56,17 +57,25 @@ import com.ssafy.tiggle.presentation.ui.theme.TiggleSkyBlue @Composable fun OpenAccountScreen( modifier: Modifier = Modifier, - viewModel: OpenAccountViewModel = viewModel(), - onBackClick: () -> Unit = {} - + viewModel: OpenAccountViewModel = hiltViewModel(), + onBackClick: () -> Unit = {}, + onFinish: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() + // 공통 Back 핸들러: 첫 단계면 pop, 아니면 단계-뒤로 + val handleTopBack: () -> Unit = { + if (uiState.openAccountStep == OpenAccountStep.INFO) { + onBackClick() // 스택에서 화면 제거 + } else { + viewModel.goToPreviousStep() + } + } when (uiState.openAccountStep) { OpenAccountStep.INFO -> { AccountInfoInputScreen( uiState = uiState, - onBackClick = onBackClick, + onBackClick = handleTopBack, onTargetDonationAmountChange = viewModel::updateTargetDonationAmount, onPiggyBankNameChange = viewModel::updatePiggyBankName, onNextClick = { viewModel.goToNextStep() } @@ -76,7 +85,7 @@ fun OpenAccountScreen( OpenAccountStep.TERMS -> { TermsAgreementScreen( uiState = uiState, - onBackClick = { viewModel.goToPreviousStep() }, + onBackClick = handleTopBack, onTermsChange = viewModel::updateTermsAgreement, onNextClick = { viewModel.goToNextStep() } ) @@ -85,8 +94,9 @@ fun OpenAccountScreen( OpenAccountStep.CERTIFICATION -> { CertificateScreen( uiState = uiState, - onBackClick = { viewModel.goToPreviousStep() }, - onNextClick = { viewModel.goToNextStep() } + onPhoneNumChange = viewModel::updatePhoneNum, + onBackClick = handleTopBack, + onNextClick = viewModel::sendSMS ) } @@ -94,15 +104,16 @@ fun OpenAccountScreen( CodeScreen( uiState = uiState, onCodeChange = viewModel::updateCode, - onBackClick = { viewModel.goToPreviousStep() }, - onNextClick = { viewModel.goToNextStep() } + onBackClick = handleTopBack, + onNextClick = viewModel::verifySMS, + onResendClick = viewModel::resendSMS ) } OpenAccountStep.SUCCESS -> { SuccessScreen( uiState = uiState, - onBackClick = { viewModel.goToPreviousStep() }, + onFinish = onFinish, ) } } @@ -122,7 +133,8 @@ fun AccountInfoInputScreen( onBackClick = onBackClick, bottomButton = { val nextEnabled = - uiState.amountInput.isNotBlank() && // 금액 입력됨 + uiState.piggyBankAccount.targetDonationAmount.toString() + .isNotBlank() && // 금액 입력됨 uiState.piggyBankAccount.piggyBankName.isNotBlank() && // 이름 입력됨 uiState.piggyBankAccount.amountError == null && // 금액 에러 없음 uiState.piggyBankAccount.piggyBankNameError == null // 이름 에러 없음 @@ -186,7 +198,7 @@ fun AccountInfoInputScreen( Spacer(Modifier.height(8.dp)) QuickAmountRow( - selected = uiState.amountInput, + selected = uiState.piggyBankAccount.targetDonationAmount.toString(), onSelect = onTargetDonationAmountChange ) @@ -194,7 +206,7 @@ fun AccountInfoInputScreen( // 금액 직접 입력 OutlinedTextField( - value = uiState.amountInput, + value = uiState.piggyBankAccount.targetDonationAmount.toString(), onValueChange = onTargetDonationAmountChange, placeholder = { Text("기부하고 싶은 금액을 입력하세요") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -454,11 +466,21 @@ private fun TermsAgreementScreen( fun CertificateScreen( uiState: OpenAccountState, onBackClick: () -> Unit, + onPhoneNumChange: (String) -> Unit, onNextClick: () -> Unit ) { TiggleScreenLayout( showBackButton = true, onBackClick = onBackClick, + bottomButton = { + TiggleButton( + text = "인증하기", + onClick = onNextClick, + enabled = !uiState.isLoading && + uiState.piggyBankAccount.phoneNum.isNotBlank() && + uiState.piggyBankAccount.phoneNumError == null + ) + } ) {} Column(Modifier.padding(16.dp)) { @@ -469,32 +491,32 @@ fun CertificateScreen( .padding(60.dp, 15.dp), horizontalArrangement = Arrangement.Start ) { - Text("티끌 계좌 개설", style = AppTypography.headlineLarge, fontSize = 20.sp) - } Spacer(Modifier.height(100.dp)) - //상단 설명 Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "본인 인증", - color = Color.Black, - fontSize = 22.sp, - style = AppTypography.headlineLarge, - ) - Spacer(Modifier.height(6.dp)) - Text( - text = "금융 서비스 이용을 위해\n 본인 인증을 진행해주세요.", - color = TiggleGrayText, - fontSize = 13.sp, - style = AppTypography.bodySmall, - textAlign = TextAlign.Center - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "본인 인증", + color = Color.Black, + fontSize = 22.sp, + style = AppTypography.headlineLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "금융 서비스 이용을 위해\n 본인 인증을 진행해주세요.", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall, + textAlign = TextAlign.Center + ) + } Spacer(Modifier.height(100.dp)) Row( @@ -502,8 +524,7 @@ fun CertificateScreen( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .border(1.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) - .padding(16.dp) - .clickable { onNextClick() }, + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -522,7 +543,7 @@ fun CertificateScreen( } // 텍스트 영역 - Column { + Column() { Text( text = "휴대폰 인증", style = AppTypography.bodyLarge, @@ -536,6 +557,18 @@ fun CertificateScreen( } } + + } + Column(Modifier.padding(20.dp, 0.dp)) { + TiggleTextField( + uiState.piggyBankAccount.phoneNum, + onValueChange = onPhoneNumChange, + label = "", + placeholder = "휴대폰 번호를 입력해주세요.", + keyboardType = KeyboardType.Number, + isError = uiState.piggyBankAccount.phoneNumError != null, + errorMessage = uiState.piggyBankAccount.phoneNumError + ) } } } @@ -548,10 +581,10 @@ fun CodeScreen( onNextClick: () -> Unit, onResendClick: () -> Unit = {} // 재전송 클릭 (옵션) ) { - // codeInput: OpenAccountState에 문자열로 가지고 있다고 가정 + val code = uiState.piggyBankAccount.certificateCode val codeError = uiState.piggyBankAccount.codeError - val nextEnabled = code.toString().length == 6 && codeError == null + val nextEnabled = code.length == 6 TiggleScreenLayout( showBackButton = true, @@ -613,23 +646,36 @@ fun CodeScreen( Spacer(Modifier.height(12.dp)) OtpCodeInput( - value = code.toString(), + value = code, onValueChange = onCodeChange, error = codeError, boxCount = 6 ) Spacer(Modifier.height(12.dp)) - if (codeError != null) { - Text(codeError, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) - } else { - Text( - "인증번호 6자리를 입력해주세요.", - style = AppTypography.bodySmall, - color = TiggleGrayText, - fontSize = 12.sp, - textAlign = TextAlign.Center - ) + when { + codeError != null -> { + Text(codeError, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } + + uiState.errorMessage != null -> { + // ✅ 전역 에러 메시지도 보이게 (서버 오류/네트워크 오류 등) + Text( + uiState.errorMessage!!, + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp + ) + } + + else -> { + Text( + "인증번호 6자리를 입력해주세요.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 12.sp, + textAlign = TextAlign.Center + ) + } } } @@ -644,7 +690,11 @@ fun CodeScreen( .padding(vertical = 12.dp), contentAlignment = Alignment.Center ) { - Text("남은 인증 시도 횟수: 3회", style = AppTypography.bodySmall, color = Color.Black) + Text( + "남은 인증 시도 횟수: ${uiState.piggyBankAccount.attemptsLeft}회", + style = AppTypography.bodySmall, + color = Color.Black + ) } Spacer(Modifier.height(36.dp)) @@ -735,15 +785,15 @@ private fun OtpCodeInput( @Composable private fun SuccessScreen( uiState: OpenAccountState, - onBackClick: () -> Unit, + onFinish: () -> Unit, ) { TiggleScreenLayout( - showBackButton = true, - onBackClick = onBackClick, + showBackButton = false, bottomButton = { TiggleButton( text = "확인", - onClick = {} + onClick = onFinish, + enabled = true ) } ) {} @@ -807,10 +857,9 @@ fun Preview_AccountInfoInput() { piggyBankName = "천사 꿀꿀이", amountError = null, piggyBankNameError = null, - certificateCode = 0, + certificateCode = "", codeError = null ), - amountInput = "5000", termsData = TermsData() ), onBackClick = {}, @@ -875,7 +924,8 @@ fun Preview_CertificateScreen() { CertificateScreen( uiState = OpenAccountState(openAccountStep = OpenAccountStep.CERTIFICATION), onBackClick = {}, - onNextClick = {} + onNextClick = {}, + onPhoneNumChange = {} ) } @@ -886,7 +936,7 @@ fun Preview_CodeScreen_Empty() { uiState = OpenAccountState( openAccountStep = OpenAccountStep.CODE, piggyBankAccount = OpenAccount( - certificateCode = 0, + certificateCode = "", codeError = null ) ), @@ -903,7 +953,7 @@ fun Preview_CodeScreen_Filled() { uiState = OpenAccountState( openAccountStep = OpenAccountStep.CODE, piggyBankAccount = OpenAccount( - certificateCode = 123456, + certificateCode = "123456", codeError = null ) ), @@ -920,7 +970,7 @@ fun Preview_CodeScreen_Error() { uiState = OpenAccountState( openAccountStep = OpenAccountStep.CODE, piggyBankAccount = OpenAccount( - certificateCode = 123450, + certificateCode = "123450", codeError = "인증번호가 일치하지 않습니다." ) ), @@ -935,7 +985,7 @@ fun Preview_CodeScreen_Error() { fun Preview_SuccessScreen() { SuccessScreen( uiState = OpenAccountState(openAccountStep = OpenAccountStep.SUCCESS), - onBackClick = {} + onFinish = {} ) } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt index ef92a3f..b70f2bb 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt @@ -8,9 +8,7 @@ data class OpenAccountState( val openAccountStep: OpenAccountStep = OpenAccountStep.INFO, val piggyBankAccount: OpenAccount = OpenAccount(), - val amountInput: String = "", - val certificateCode: String = "", // 약관 동의 val termsData: TermsData = TermsData(), ) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt index deb81c0..a517ebe 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt @@ -1,16 +1,20 @@ package com.ssafy.tiggle.presentation.ui.piggybank import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.ssafy.tiggle.domain.entity.piggybank.ValidationField +import com.ssafy.tiggle.domain.usecase.piggybank.PiggyBankUseCases import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class OpenAccountViewModel @Inject constructor( - + val useCases: PiggyBankUseCases ) : ViewModel() { private val _uiState = MutableStateFlow(OpenAccountState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -58,9 +62,8 @@ class OpenAccountViewModel @Inject constructor( val error = _uiState.value.piggyBankAccount.validateTargetDonationAmount(amount) _uiState.value = _uiState.value.copy( - amountInput = amount, piggyBankAccount = _uiState.value.piggyBankAccount.copy( - targetDonationAmount = amount.toIntOrNull() ?: 0, + targetDonationAmount = amount.toLongOrNull() ?: 0, amountError = error ) ) @@ -75,15 +78,26 @@ class OpenAccountViewModel @Inject constructor( } fun updateCode(code: String) { - val error = _uiState.value.piggyBankAccount.validateCode(code) + val digits = code.filter(Char::isDigit).take(6) + val error = _uiState.value.piggyBankAccount.validateCode(digits) + _uiState.value = _uiState.value.copy( piggyBankAccount = _uiState.value.piggyBankAccount.copy( - certificateCode = code.toIntOrNull() ?: 0, + certificateCode = digits, codeError = error ) ) } + fun updatePhoneNum(phoneNum: String) { + val validated = _uiState.value.piggyBankAccount + .copy(phoneNum = phoneNum) + .validateField(ValidationField.PHONE) + + _uiState.value = _uiState.value.copy(piggyBankAccount = validated) + + } + fun goToPreviousStep() { val currentStep = _uiState.value.openAccountStep val previousStep = when (currentStep) { @@ -128,7 +142,18 @@ class OpenAccountViewModel @Inject constructor( } OpenAccountStep.CERTIFICATION -> { - true + val validated = _uiState.value.piggyBankAccount + .validateField(ValidationField.PHONE) + _uiState.value = _uiState.value.copy(piggyBankAccount = validated) + + if (validated.phoneNumError != null) { + _uiState.value = _uiState.value.copy( + errorMessage = validated.phoneNumError + ) + false + } else { + true + } } OpenAccountStep.CODE -> { @@ -153,4 +178,183 @@ class OpenAccountViewModel @Inject constructor( } + fun createPiggyBank() { + val name = _uiState.value.piggyBankAccount.piggyBankName + val targetAmount = _uiState.value.piggyBankAccount.targetDonationAmount + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = useCases.createPiggyBankUseCase(name, targetAmount, 1) + result.onSuccess { + _uiState.update { it.copy(isLoading = false) } + goToNextStep() + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "계좌개설 정보 보내기에 실패했습니다." + ) + } + } + } + } + + private fun resetWhenExhausted(msg: String) { + _uiState.update { + it.copy( + openAccountStep = OpenAccountStep.INFO, + errorMessage = msg, + piggyBankAccount = it.piggyBankAccount.copy( + certificateCode = "", + codeError = null, + attemptsLeft = 3 + ) + ) + } + } + + fun sendSMS() { + val phoneNum = _uiState.value.piggyBankAccount.phoneNum + + // 1) 로컬 유효성 먼저 체크 (11자리 숫자) + val phoneErr = _uiState.value.piggyBankAccount.validatePhoneNum(phoneNum) + if (phoneErr != null) { + _uiState.update { + it.copy( + piggyBankAccount = it.piggyBankAccount.copy(phoneNumError = phoneErr), + errorMessage = phoneErr + ) + } + return + } + + // 2) 전송 가능 횟수 체크 + val left = _uiState.value.piggyBankAccount.attemptsLeft + if (left <= 0) { + resetWhenExhausted("인증번호 전송 가능 횟수를 모두 사용했어요. 처음 화면으로 돌아갑니다.") + return + } + + // 3) 즉시 CODE 화면으로 이동 + _uiState.update { + it.copy( + openAccountStep = OpenAccountStep.CODE, + isLoading = true, + errorMessage = null, + piggyBankAccount = it.piggyBankAccount.copy( + certificateCode = "", + codeError = null + ) + ) + } + + // 4) SMS 전송은 비동기로 진행하면서 시도 횟수 차감 + viewModelScope.launch { + val result = useCases.sendSMSUseCase(phoneNum, "account_opening") + result.onSuccess { + _uiState.update { st -> + st.copy( + isLoading = false, + piggyBankAccount = st.piggyBankAccount.copy( + attemptsLeft = (st.piggyBankAccount.attemptsLeft - 1).coerceAtLeast(0) + ) + ) + } + }.onFailure { e -> + _uiState.update { st -> + val nextLeft = (st.piggyBankAccount.attemptsLeft - 1).coerceAtLeast(0) + st.copy( + isLoading = false, + errorMessage = e.message ?: "인증번호 발송을 실패했습니다.", + piggyBankAccount = st.piggyBankAccount.copy( + attemptsLeft = nextLeft + ) + ) + } + if (_uiState.value.piggyBankAccount.attemptsLeft <= 0) { + resetWhenExhausted("인증번호 전송 가능 횟수를 모두 사용했어요. 처음 화면으로 돌아갑니다.") + } + } + } + } + + + /** 인증코드 확인 → verificationToken 저장 → 주계좌 등록 → 성공화면 */ + fun verifySMS() { + val phoneNum = _uiState.value.piggyBankAccount.phoneNum + val code = _uiState.value.piggyBankAccount.certificateCode + + if (code.length != 6) { + _uiState.update { it.copy(errorMessage = "인증번호 6자리를 입력해주세요.") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + val result = useCases.verifySMSUseCase(phoneNum, code, "account_opening") + result.onSuccess { response -> + if (response.match) { + // 일치하면 계좌 개설 요청 + createPiggyBank() + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "인증번호가 일치하지 않습니다.", + piggyBankAccount = it.piggyBankAccount.copy( + codeError = "인증번호가 일치하지 않습니다." + ) + ) + } + } + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "인증번호 인증에 실패했습니다." + ) + } + } + } + } + + + fun resendSMS() { + val phoneNum = _uiState.value.piggyBankAccount.phoneNum + val left = _uiState.value.piggyBankAccount.attemptsLeft + if (left <= 0) { + resetWhenExhausted("인증번호 전송 가능 횟수를 모두 사용했어요. 처음 화면으로 돌아갑니다.") + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = useCases.sendSMSUseCase(phoneNum, "account_opening") + result.onSuccess { + // ✅ 재전송은 같은 단계(CODE)에 머무름 + _uiState.update { st -> + st.copy( + isLoading = false, + piggyBankAccount = st.piggyBankAccount.copy( + attemptsLeft = (st.piggyBankAccount.attemptsLeft - 1).coerceAtLeast(0) + ) + ) + } + }.onFailure { e -> + _uiState.update { st -> + val nextLeft = (st.piggyBankAccount.attemptsLeft - 1).coerceAtLeast(0) + st.copy( + isLoading = false, + errorMessage = e.message ?: "인증번호 재전송에 실패했습니다.", + piggyBankAccount = st.piggyBankAccount.copy(attemptsLeft = nextLeft) + ) + } + if (_uiState.value.piggyBankAccount.attemptsLeft <= 0) { + resetWhenExhausted("인증번호 전송 가능 횟수를 모두 사용했어요. 처음 화면으로 돌아갑니다.") + } + } + } + } + } \ No newline at end of file