From 0e8006a296292df7f427fa57c3384d9663a48d6d Mon Sep 17 00:00:00 2001 From: zziwonCHOI Date: Sat, 23 Aug 2025 22:37:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20OPS-62=20=EA=B3=84=EC=A2=8C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../domain/entity/account/RegisterAccount.kt | 63 ++ .../navigation/NavigationGraph.kt | 8 + .../tiggle/presentation/navigation/Screen.kt | 3 + .../ui/piggybank/PiggyBankScreen.kt | 7 +- .../ui/piggybank/RegisterAccountScreen.kt | 842 ++++++++++++++++++ .../ui/piggybank/RegisterAccountState.kt | 11 + .../ui/piggybank/RegisterAccountStep.kt | 9 + .../ui/piggybank/RegisterAccountViewModel.kt | 103 +++ app/src/main/res/drawable/bank.webp | Bin 0 -> 2650 bytes app/src/main/res/drawable/check.webp | Bin 0 -> 3194 bytes app/src/main/res/drawable/lock.webp | Bin 0 -> 566 bytes app/src/main/res/drawable/money.webp | Bin 0 -> 538 bytes gradle/libs.versions.toml | 2 + 14 files changed, 1046 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountStep.kt create mode 100644 app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt create mode 100644 app/src/main/res/drawable/bank.webp create mode 100644 app/src/main/res/drawable/check.webp create mode 100644 app/src/main/res/drawable/lock.webp create mode 100644 app/src/main/res/drawable/money.webp diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8cea736..3d81523 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(libs.hilt.android) implementation(libs.androidx.foundation) implementation(libs.material3) + implementation(libs.androidx.room.ktx) ksp(libs.hilt.compiler) implementation(libs.hilt.navigation.compose) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt new file mode 100644 index 0000000..f09282a --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt @@ -0,0 +1,63 @@ +package com.ssafy.tiggle.domain.entity.account + +data class RegisterAccount( + val accountNum: String = "", + val owner:String="", + val code:Int=0, + val attemptsLeft: Int=3, + val bankName:String="", + val date:String="", + + val accountNumError: String? = null, + val codeError:String?=null +) { + /** 숫자 외 문자를 제거한 정규화(붙여넣기 대비) */ + private fun sanitize(raw: String): String = raw.filter { it.isDigit() } + + /** 계좌번호 유효성 검사 */ + fun validateAccountNum(input: String = accountNum): String? = when { + input.isBlank() -> "계좌번호를 입력해주세요." + !input.all { it.isDigit() } -> "숫자만 입력할 수 있습니다." + else -> null + } + + /** 외부에서 raw 입력을 받아 정규화 + 검증해서 갱신 */ + fun withValidation(raw: String): RegisterAccount { + val normalized = sanitize(raw) + return copy( + accountNum = normalized, + accountNumError = validateAccountNum(normalized) + ) + } + + /** + * 특정 필드만 유효성 검사를 수행하고 업데이트된 인스턴스 반환 + */ + fun validateField(field: ValidationRegisterField): RegisterAccount { + return when (field) { + ValidationRegisterField.ACCOUNT -> copy( + accountNumError = validateAccountNum( + accountNum + ) + ) + ValidationRegisterField.CODE -> + copy(codeError = validateCode(code.toString())) + } + } + /** + * 코드 유효성 검사 + */ + fun validateCode(input: String): String? { + return when { + input.isBlank() -> "인증 코드를 입력해주세요." + !input.matches(Regex("^[0-9]+$")) -> "숫자만 입력할 수 있습니다." + else -> null + } + } +} + + +enum class ValidationRegisterField { + ACCOUNT, + CODE +} 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 02bfa56..d619e25 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 @@ -16,6 +16,7 @@ import com.ssafy.tiggle.presentation.ui.auth.login.LoginScreen import com.ssafy.tiggle.presentation.ui.auth.signup.SignUpScreen import com.ssafy.tiggle.presentation.ui.piggybank.OpenAccountScreen import com.ssafy.tiggle.presentation.ui.piggybank.PiggyBankScreen +import com.ssafy.tiggle.presentation.ui.piggybank.RegisterAccountScreen /** * 앱의 메인 네비게이션 @@ -78,6 +79,9 @@ fun NavigationGraph() { onOpenAccountClick = { navBackStack.add(Screen.OpenAccount) }, + onRegisterAccountClick = { + navBackStack.add(Screen.RegisterAccount) + }, onBackClick = { navBackStack.removeLastOrNull() } @@ -88,6 +92,10 @@ fun NavigationGraph() { OpenAccountScreen() } + is Screen.RegisterAccount -> NavEntry(key) { + RegisterAccountScreen() + } + else -> throw IllegalArgumentException("Unknown route: $key") } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt index 5128fc9..19874c7 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt @@ -29,4 +29,7 @@ sealed interface Screen : NavKey { @Serializable object OpenAccount : Screen + + @Serializable + object RegisterAccount : Screen } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt index 36df5f0..0dd2014 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt @@ -56,8 +56,9 @@ import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText @Composable fun PiggyBankScreen( modifier: Modifier = Modifier, - onOpenAccountClick:()-> Unit={}, - onBackClick:()-> Unit={}, + onOpenAccountClick: () -> Unit = {}, + onRegisterAccountClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, viewModel: PiggyBankViewModel = viewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -113,7 +114,7 @@ fun PiggyBankScreen( DottedActionCard( title = "내 계좌 등록", desc = "나의 계좌를 등록하면\n티끌 저금통에 잔돈이 자동으로 기부됩니다.", - onClick = { /* TODO: 네비게이션 */ } + onClick = onRegisterAccountClick ) } Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt new file mode 100644 index 0000000..fa5da76 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt @@ -0,0 +1,842 @@ +package com.ssafy.tiggle.presentation.ui.piggybank + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +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 com.ssafy.tiggle.R +import com.ssafy.tiggle.domain.entity.account.RegisterAccount +import com.ssafy.tiggle.presentation.ui.components.TiggleButton +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.TiggleGrayLight +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText +import com.ssafy.tiggle.presentation.ui.theme.TiggleSkyBlue + +@Composable +fun RegisterAccountScreen( + modifier: Modifier = Modifier, + viewModel: RegisterAccountViewModel = viewModel(), + onBackClick: () -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsState() + + when (uiState.registerAccountStep) { + RegisterAccountStep.ACCOUNT -> { + AccountInputScreen( + uiState = uiState, + onBackClick = onBackClick, + onAccountChange = viewModel::updateAccountNum, + onNextClick = { viewModel.goToNextStep() } + ) + } + + RegisterAccountStep.ACCOUNTSUCCESS -> { + AccountInputSuccessScreen( + uiState = uiState, + onBackClick = onBackClick, + onNextClick = { viewModel.goToNextStep() } + ) + + } + + RegisterAccountStep.SENDCODE -> { + SendCodeScreen( + uiState = uiState, + onBackClick = onBackClick, + onNextClick = { viewModel.goToNextStep() } + ) + } + + RegisterAccountStep.CERTIFICATION -> { + CertificationScreen( + uiState = uiState, + onCodeChange = viewModel::updateCode, + onBackClick = { viewModel.goToPreviousStep() }, + onResendClick = { viewModel.goToPreviousStep() }, + onNextClick = { viewModel.goToNextStep() } + ) + } + + RegisterAccountStep.SUCCESS -> { + RegisterSuccessScreen( + uiState = uiState, + onBackClick = onBackClick, + onNextClick = { viewModel.goToNextStep() } + ) + } + + } +} + +@Composable +fun AccountInputScreen( + uiState: RegisterAccountState, + onBackClick: () -> Unit, + onAccountChange: (String) -> Unit, + onNextClick: () -> Unit, +) { + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + val nextEnabled = + uiState.registerAccount.accountNum.isNotBlank() && + uiState.registerAccount.accountNumError == null + TiggleButton( + text = "확인", + onClick = onNextClick, + enabled = nextEnabled, + ) + } + ) {} + + Column(Modifier.padding(16.dp)) { + // 상단 제목/뒤로 + Row( + Modifier + .fillMaxWidth() + .padding(60.dp, 15.dp), + horizontalArrangement = Arrangement.Start + ) { + + Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + + } + + Spacer(Modifier.height(16.dp)) + + //상단 설명 + Image( + painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", + Modifier + .size(110.dp) + .align(Alignment.CenterHorizontally) + ) + + Spacer(Modifier.height(16.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(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 + ) + } + Spacer(Modifier.height(100.dp)) + + Column(modifier = Modifier.padding(20.dp)) { + Text(text = "신한 은행 계좌번호", style = AppTypography.bodyLarge, fontSize = 15.sp) + Spacer(Modifier.height(3.dp)) + TiggleTextField( + uiState.registerAccount.accountNum, + onValueChange = onAccountChange, + label = "", + placeholder = "계좌번호를 입력해주세요.", + keyboardType = KeyboardType.Number, + isError = uiState.registerAccount.accountNumError != null, + errorMessage = uiState.registerAccount.accountNumError + ) + } + + } +} + +@Composable +fun AccountInputSuccessScreen( + uiState: RegisterAccountState, + onBackClick: () -> Unit, + onNextClick: () -> Unit, +) { + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + val nextEnabled = + uiState.registerAccount.accountNum.isNotBlank() && + uiState.registerAccount.accountNumError == null + TiggleButton( + text = "1원 인증 시작", + onClick = onNextClick, + enabled = nextEnabled, + ) + } + ) {} + + Column(Modifier.padding(16.dp)) { + // 상단 제목/뒤로 + Row( + Modifier + .fillMaxWidth() + .padding(60.dp, 15.dp), + horizontalArrangement = Arrangement.Start + ) { + + Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + + } + + Spacer(Modifier.height(16.dp)) + + //상단 설명 + Image( + painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", + Modifier + .size(110.dp) + .align(Alignment.CenterHorizontally) + ) + + Spacer(Modifier.height(16.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(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 + ) + } + Spacer(Modifier.height(100.dp)) + + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.SpaceBetween) { + Text( + text = uiState.registerAccount.owner, + style = AppTypography.bodyLarge, + fontSize = 30.sp + ) + Spacer(Modifier.height(3.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .border(2.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) + .padding(10.dp, 15.dp), + verticalAlignment = Alignment.CenterVertically + + ) { + Image( + painter = painterResource(id = R.drawable.shinhan), + contentDescription = "신한 로고", + Modifier.size(50.dp) + ) + Spacer(Modifier.height(10.dp)) + Text( + "신한은행 ${uiState.registerAccount.accountNum}", + style = AppTypography.bodyMedium, fontSize = 20.sp, textAlign = TextAlign.Center + ) + } + + Spacer(Modifier.height(100.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .background(TiggleSkyBlue) // 연한 파란색 배경 + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 아이콘 + Image( + painter = painterResource(id = R.drawable.lock), // 자물쇠 아이콘 리소스 + contentDescription = "보안 아이콘", + modifier = Modifier.size(24.dp) + ) + + // 텍스트 + Column { + Text( + text = "안전한 계좌 등록", + style = AppTypography.bodyMedium, + color = Color(0xFF0077CC) // 강조 파란색 + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "계좌 정보는 암호화되어 안전하게 보관되며,\n" + + "1원 인증을 통해 계좌 소유주를 확인합니다.\n" + + "잔돈 적립과 더치페이 외에는 사용되지 않습니다.", + style = AppTypography.bodySmall, + color = TiggleGrayText + ) + } + } + } + + } +} + +@Composable +fun SendCodeScreen( + uiState: RegisterAccountState, + onBackClick: () -> Unit, + onNextClick: () -> Unit, +) { + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + TiggleButton( + text = "인증번호 입력하기", + onClick = onNextClick, + enabled = true + ) + } + ) {} + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + Modifier + .fillMaxWidth() + .padding(60.dp, 15.dp), + horizontalArrangement = Arrangement.Start + ) { + // 상단 타이틀 + Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + } + + Spacer(Modifier.height(40.dp)) + + Image( + painter = painterResource(id = R.drawable.check), + contentDescription = "송금 완료 아이콘", + modifier = Modifier.size(170.dp) + ) + + Spacer(Modifier.height(16.dp)) + + // 완료 안내 + Text("1원 송금 완료", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "계좌로 1원이 입금되었습니다.\n입금자명을 확인해주세요.", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) + + // 계좌 정보 박스 + Column(Modifier.padding(20.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.shinhan), + contentDescription = "신한 로고", + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = "신한은행 123-456-****12 계좌로\n1원이 성공적으로 입금되었습니다.", + style = AppTypography.bodyMedium + ) + } + + Spacer(Modifier.height(20.dp)) + + // 입금자명 확인 방법 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .padding(16.dp) + ) { + Text("입금자명 확인 방법", style = AppTypography.bodyMedium, fontSize = 16.sp) + Spacer(Modifier.height(12.dp)) + Text("1. 계좌 예금주명을 입력해주세요.", style = AppTypography.bodySmall) + Text("2. 입금자명: 티끌1234", style = AppTypography.bodySmall) + Text("3. 위 4자리 \"1234\" 확인", style = AppTypography.bodySmall) + } + + Spacer(Modifier.height(10.dp)) + + // 입금 확인 안내 박스 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .background(TiggleSkyBlue) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Image( + painter = painterResource(id = R.drawable.money), // 동전 or 금색 아이콘 + contentDescription = "입금 아이콘", + modifier = Modifier.size(24.dp) + ) + Column { + Text( + text = "입금 확인 방법", + style = AppTypography.bodyMedium, + color = Color(0xFF0077CC) + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "• 은행 앱에서 입출금 내역 확인\n" + + "• SMS 입금 알림 확인\n" + + "• 인터넷뱅킹에서 거래내역 조회\n" + + "• 입금자명 끝 4자리 숫자가 인증번호입니다.", + style = AppTypography.bodySmall, + color = TiggleGrayText + ) + } + } + } + } +} + +@Composable +fun CertificationScreen( + uiState: RegisterAccountState, + onBackClick: () -> Unit, + onCodeChange: (String) -> Unit, + onResendClick: () -> Unit, + onNextClick: () -> Unit, +) { + val code = uiState.registerAccount.code.toString() ?: "" + val error = uiState.registerAccount.codeError + val attemptsLeft = uiState.registerAccount.attemptsLeft ?: 3 + + val nextEnabled = code.length == 4 && error == null + + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + TiggleButton( + text = "인증 완료", + onClick = onNextClick, + enabled = nextEnabled + ) + } + ) {} + + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 타이틀 + Row( + Modifier + .fillMaxWidth() + .padding(60.dp, 15.dp), + horizontalArrangement = Arrangement.Start + ) { + Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + } + + Spacer(Modifier.height(30.dp)) + + Column( + Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("인증번호 입력", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "계좌로 입금된 1원의 입금자명\n뒤 4자리를 입력하세요.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 13.sp, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(50.dp)) + + // 입력 카드 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Text("인증번호 (4자리)", style = AppTypography.bodyLarge, textAlign = TextAlign.Center) + Spacer(Modifier.height(12.dp)) + + OtpCodeBoxes( + value = code, + onValueChange = onCodeChange, + error = error, + boxCount = 4 + ) + + Spacer(Modifier.height(8.dp)) + if (error != null) { + Text(error, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } else { + Text( + "입금자명에서 뒤 4자리 숫자를 입력하세요.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 12.sp + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // 남은 시도 횟수 + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TiggleSkyBlue) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text("남은 인증 시도 횟수: ${attemptsLeft}회", style = AppTypography.bodySmall) + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = "인증번호를 받지 못하셨나요?", + style = AppTypography.bodySmall, + color = TiggleGrayText + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onResendClick, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 8.dp) + ) { + Text("1원 재송금", color = TiggleBlue) + } + } + + Spacer(Modifier.height(60.dp)) + + } +} + +/** OTP 4칸 입력 박스: 숨겨진 텍스트필드 + 박스 시각화 */ +@Composable +private fun OtpCodeBoxes( + value: String, + onValueChange: (String) -> Unit, + error: String?, + boxCount: Int, + modifier: Modifier = Modifier +) { + val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + // 숨겨진 입력 필드 (0 크기 금지 → 최소 크기 & alpha 0) + BasicTextField( + value = value, + onValueChange = { raw -> + val filtered = raw.filter { it.isDigit() }.take(boxCount) + onValueChange(filtered) + }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = KeyboardType.Number + ), + singleLine = true, + modifier = Modifier + .size(1.dp) + .alpha(0f) + .focusRequester(focusRequester) + ) { /* no visible text */ } + + // 박스 렌더 + Row( + modifier = modifier + .fillMaxWidth() + .clickable { + focusRequester.requestFocus() + keyboard?.show() + }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(boxCount) { i -> + val ch = value.getOrNull(i)?.toString().orEmpty() + Box( + modifier = Modifier + .weight(1f) + .height(52.dp) + .clip(RoundedCornerShape(12.dp)) + .border( + width = 1.dp, + color = if (error != null) + MaterialTheme.colorScheme.error + else TiggleGrayLight, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = ch, + style = AppTypography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +fun RegisterSuccessScreen( + uiState: RegisterAccountState, + onBackClick: () -> Unit, + onNextClick: () -> Unit, +) { + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + TiggleButton( + text = "확인", + onClick = onNextClick, + enabled = true + ) + } + ) {} + + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 상단 타이틀 + Row( + Modifier + .fillMaxWidth() + .padding(60.dp, 15.dp), + horizontalArrangement = Arrangement.Start + ) { + Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + } + + Spacer(Modifier.height(40.dp)) + + Image( + painter = painterResource(id = R.drawable.happy), + contentDescription = "계좌 등록 완료", + modifier = Modifier.size(150.dp) + ) + + Spacer(Modifier.height(24.dp)) + + // 안내 문구 + Text("계좌 등록 완료!", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "1원 인증이 성공적으로 완료되어\n계좌가 등록되었습니다.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 13.sp, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) + + // 계좌 정보 박스 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .padding(16.dp) + ) { + Text("등록된 계좌", style = AppTypography.bodyMedium, fontSize = 16.sp, color = Color.Black) + + Spacer(Modifier.height(16.dp)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("은행", style = AppTypography.bodySmall, color = TiggleGrayText) + Text(uiState.registerAccount.bankName, style = AppTypography.bodySmall, color = Color.Black) + } + + Spacer(Modifier.height(8.dp)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("계좌번호", style = AppTypography.bodySmall, color = TiggleGrayText) + Text(uiState.registerAccount.accountNum, style = AppTypography.bodySmall, color = Color.Black) + } + + Spacer(Modifier.height(8.dp)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("등록일시", style = AppTypography.bodySmall, color = TiggleGrayText) + Text(uiState.registerAccount.date, style = AppTypography.bodySmall, color = Color.Black) + } + } + } +} + + +@Preview +@Composable +fun AccountInputPreview() { + AccountInputScreen( + uiState = RegisterAccountState( + registerAccountStep = RegisterAccountStep.ACCOUNT, + registerAccount = RegisterAccount( + accountNum = "110123456789", + accountNumError = null + ) + ), + onBackClick = {}, + onAccountChange = {}, + onNextClick = {} + ) +} + +@Preview +@Composable +fun AccountInputSuccessPreview() { + AccountInputSuccessScreen( + uiState = RegisterAccountState( + registerAccountStep = RegisterAccountStep.ACCOUNTSUCCESS, + registerAccount = RegisterAccount( + accountNum = "110123456789", + owner = "최지원", + accountNumError = null + ) + ), + onBackClick = {}, + onNextClick = {} + ) +} + +@Preview +@Composable +fun PreviewOneWonTransferScreen() { + SendCodeScreen( + uiState = RegisterAccountState( + registerAccountStep = RegisterAccountStep.SENDCODE, + registerAccount = RegisterAccount(accountNum = "1234567890") + ), + onBackClick = {}, + onNextClick = {} + ) +} + +@Preview(showBackground = true, name = "CertificationScreen - Success") +@Composable +fun PreviewCertificationScreen_Success() { + CertificationScreen( + uiState = RegisterAccountState( + registerAccountStep = RegisterAccountStep.CERTIFICATION, + registerAccount = RegisterAccount( + accountNum = "110123456789", + owner = "최지원", + code = 1234, + codeError = null, + attemptsLeft = 3 + ) + ), + onBackClick = {}, + onCodeChange = {}, + onResendClick = {}, + onNextClick = {} + ) +} + +@Preview(showBackground = true, name = "RegisterAccount Success") +@Composable +fun PreviewRegisterAccountSuccessScreen() { + RegisterSuccessScreen( + uiState = RegisterAccountState( + registerAccountStep = RegisterAccountStep.SUCCESS, + registerAccount = RegisterAccount( + bankName = "신한은행", + accountNum = "939393948394", + date = "2020-10-20 39:39" + ) + ), + onBackClick = {}, + onNextClick = {} + ) +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt new file mode 100644 index 0000000..c4643d5 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt @@ -0,0 +1,11 @@ +package com.ssafy.tiggle.presentation.ui.piggybank + +import com.ssafy.tiggle.domain.entity.account.RegisterAccount + +data class RegisterAccountState( + val isLoading: Boolean = false, + val errorMessage: String? = null, + + val registerAccountStep: RegisterAccountStep = RegisterAccountStep.ACCOUNT, + val registerAccount: RegisterAccount = RegisterAccount(), +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountStep.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountStep.kt new file mode 100644 index 0000000..5801115 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountStep.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.presentation.ui.piggybank + +enum class RegisterAccountStep { + ACCOUNT, + ACCOUNTSUCCESS, + SENDCODE, + CERTIFICATION, + SUCCESS +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt new file mode 100644 index 0000000..5272e71 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt @@ -0,0 +1,103 @@ +package com.ssafy.tiggle.presentation.ui.piggybank + +import androidx.lifecycle.ViewModel +import com.ssafy.tiggle.domain.entity.account.ValidationRegisterField +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class RegisterAccountViewModel @Inject constructor( +) : ViewModel() { + private val _uiState = MutableStateFlow(RegisterAccountState()) + val uiState: StateFlow = _uiState.asStateFlow() + + //단계 이동 + fun goToNextStep(): Boolean { + val currentStep = _uiState.value.registerAccountStep + val canProceed = validateCurrentStep() + + if (canProceed) { + val nextStep = when (currentStep) { + RegisterAccountStep.ACCOUNT -> RegisterAccountStep.ACCOUNTSUCCESS + RegisterAccountStep.ACCOUNTSUCCESS -> RegisterAccountStep.SENDCODE + RegisterAccountStep.SENDCODE -> RegisterAccountStep.CERTIFICATION + RegisterAccountStep.CERTIFICATION -> RegisterAccountStep.SUCCESS + RegisterAccountStep.SUCCESS -> RegisterAccountStep.SUCCESS + } + + _uiState.value = _uiState.value.copy(registerAccountStep = nextStep) + } + + return canProceed + } + + private fun validateCurrentStep(): Boolean { + val currentState = _uiState.value + + return when (currentState.registerAccountStep) { + RegisterAccountStep.ACCOUNT -> { + val validated = _uiState.value.registerAccount + .validateField(ValidationRegisterField.ACCOUNT) + _uiState.value = _uiState.value.copy(registerAccount = validated) + + if (validated.accountNumError != null) { + _uiState.value = _uiState.value.copy( + errorMessage = validated.accountNumError + ) + false + } + true + } + + RegisterAccountStep.ACCOUNTSUCCESS -> { + true + } + + RegisterAccountStep.SENDCODE -> { + true + } + + RegisterAccountStep.CERTIFICATION -> { + true + } + + RegisterAccountStep.SUCCESS -> { + true + } + } + } + + fun goToPreviousStep() { + val currentStep = _uiState.value.registerAccountStep + val previousStep = when (currentStep) { + RegisterAccountStep.ACCOUNT -> RegisterAccountStep.ACCOUNT + RegisterAccountStep.ACCOUNTSUCCESS -> RegisterAccountStep.ACCOUNT + RegisterAccountStep.SENDCODE -> RegisterAccountStep.ACCOUNTSUCCESS + RegisterAccountStep.CERTIFICATION -> RegisterAccountStep.SENDCODE + RegisterAccountStep.SUCCESS -> RegisterAccountStep.CERTIFICATION + } + + _uiState.value = _uiState.value.copy(registerAccountStep = previousStep) + } + + // 사용자 데이터 업데이트 (도메인 엔티티의 유효성 검사 사용) + fun updateAccountNum(accountNum: String) { + val currentData = _uiState.value.registerAccount + val newData = currentData.copy(accountNum = accountNum) + .validateField(ValidationRegisterField.ACCOUNT) + _uiState.value = _uiState.value.copy(registerAccount = newData) + } + fun updateCode(code: String) { + val error = _uiState.value.registerAccount.validateCode(code) + _uiState.value = _uiState.value.copy( + registerAccount = _uiState.value.registerAccount.copy( + code = code.toIntOrNull() ?: 0, + codeError = error + ) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/bank.webp b/app/src/main/res/drawable/bank.webp new file mode 100644 index 0000000000000000000000000000000000000000..4bbbaa7413a4378978fce9b6a4f9ce5bb4d2c370 GIT binary patch literal 2650 zcmV-g3Z?Z@Nk&Fe3IG6CMM6+kP&il$0000G0001<005o<06|PpND2f100E#yZJQxU z`u>Qlv2EV1ZQHhO+qP}<%63;z_1Ly;GxLvcyt@?|)! zY-u4je@KGH?=9iFi$81uD>)+p9wXg)9QinqJ=hpo!S!Uu-##TzhB2{`1nnm>b1X+y z_GthsQAvP@>6GP~l4rx1c!UrA+Coazsm536~_O`5m3o-;}%xVnbyNj!K!)?~KD+ z!Ujna)UKo|eNV}&AT~TkaHcY42r~|EiA2I87LA8dm0?WDt06WdR&XVmF`UZbEs#hk z#G?H;W{x4v$ZG%_h6H$!E@NnwBU{6Yf5u{y*-Rb7o07Kx)-MsD@H$<_Fe}Gy3c&c2 z!0i3r5~d7x<~aI+%^=p30uES6QW^Te4Pm`7IFC*l{*2mKypT3jMo~Eq;sah_ER{RK z%l^?IANFHFM4t5Hn`k&*<;OA1BB87=K3G&Si-A*~1LFfMJ|LNeEhDMtAc2=+(fR5( zT-Z47z1!=d0ACG|WPv*l5CflExWL^D-?|E2gDyMsK2cS?(O!iVXP+> zP5U}#VOvGAe*+jlqzUO5e&zC7o?sxAF;u<_KMPA3%bA`e3u56$dV(LRQAl};C#J?U zuB!oa>+lU&PXyQXA3uJg3m7Mi9{Tmcu<%q205oI3R~P`W;>-Fwhiqltb$ss-vQ_m$ z(CN>u4tb}2-f5F}TIZcsF6Xq+oYU+tJx%jYv%J$Z?=;IhP5$6%l;xbpe{MBOb57$d z=QK=nPJ_JDDDO1S-?{A6(k-B z`Y>bIQ=S8#J5w2YoiF0ixr|{uczjh9HudR}C~V@37~s+=N>ejWY zs#ICorBqogR#X%#e){>RV#UuD#bQN8v9i>qsHf-qdk)uYB>-F+pFa{oqAeu(-f0zMQP&gnW1^@ssCIFoQ zDxUzK06vjGok^vmqah`AtJts-31=+8B!C8C5`a8I{{fPZyB4&9tNM33c7Hya|I6+N z{KNXc{La81w7$oGtHzxNQ7Xf9C7erj7vJ0cXw~m@U@j!lf##9(I1a-hf9RvK%C(Ih zbUxm!*QQvaN+F$sS;^}j>Rh1t^AdUp-)-k;4{g+wZd2RY(2o=uS*#(18m$r87~A+P z)h1-)lNN9?o1IjkCHhK@tQcD*vtvy*US%YsCB81xW~`Z4q_N}d#PRlZ;J$i*+hUf- znQ$-xgQf&o6fZ%lu5A*it=a~aBx!?z-Gf8f&FNKqp{k3SUE=EQqT#cFXZfJ6kbA}g zKqvuT3VJZ2o4|*e8Y{*e*oVSfft2?^s0M&D#CT)dzu->LENj+Q5`pF{=w;AY0092J zdH?{K^#)b(FICYJQ?2|0R#1r#$XKimv#YDL>Ey?8*#iI9u86q@`emoRn-8eTg~!(n zhuG^B5I@Qv!d`!Wy$n}!0{xk!Y8HuS8wYGDvw4*$CykD<^r^=w8FpTJd9RPo+yhs= zu`+w{<58!q{GS-Z-8j8yV8+PDKNULgc@Pm$>(EZ|L56hvr&~#(mP`I+fKJO#eQS%k z%7UmWs6Yqp;*RgBO)W3MuaY3CdOB>Do zAV`efYvB&8>FeL_pVTEZn|RIpkPI8Yq{u`J?lU;XX{)xe_c{@o!|rrxXcjQrbiTG zjS8RJKW2Q+*CRT|Pm<8=PMK+imG2PZ-aa+OoK+0iO+O#}1Lwk}W;TP6@AvAeWi&A@ z!~Av@<4M&V-{QDlArA^qsUliVe5U#cP`Axd7>?ayOn1_X{g1>4)cFwdmucyXHZ(tZoW)?hE9xuV(2Z#s~?T;6TEd5#s~@vYW} zLkqJ+2)X8M=jdDWQjwpe@I{JjCP`jN0T#qo?wVAvgxnJZ4oNhY!bns%A6XC|8MRfQ&bf#t*azsR|VxKW;=-mAmFMo_c+`9R>xbYmi IKmY&$09X1O6aWAK literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/check.webp b/app/src/main/res/drawable/check.webp new file mode 100644 index 0000000000000000000000000000000000000000..c88c88f8a4be2f0ea733138f807ff3e77c4554a7 GIT binary patch literal 3194 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