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 0000000..4bbbaa7 Binary files /dev/null and b/app/src/main/res/drawable/bank.webp differ diff --git a/app/src/main/res/drawable/check.webp b/app/src/main/res/drawable/check.webp new file mode 100644 index 0000000..c88c88f Binary files /dev/null and b/app/src/main/res/drawable/check.webp differ diff --git a/app/src/main/res/drawable/lock.webp b/app/src/main/res/drawable/lock.webp new file mode 100644 index 0000000..a08b5d1 Binary files /dev/null and b/app/src/main/res/drawable/lock.webp differ diff --git a/app/src/main/res/drawable/money.webp b/app/src/main/res/drawable/money.webp new file mode 100644 index 0000000..f3fbab7 Binary files /dev/null and b/app/src/main/res/drawable/money.webp differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bc9035..e799352 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ foundation = "1.9.0" material3 = "1.3.2" navigation3Runtime = "1.0.0-alpha06" lifecycleViewmodelNavigation3Android = "1.0.0-alpha04" +roomKtx = "2.7.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -62,6 +63,7 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }