diff --git a/README.md b/README.md index 70441f7d..313f1022 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,16 @@ - [x] 카드 목록이 비어있을 때에는 "새로운 카드를 등록해주세요" 안내가 노출되어야 한다. - [x] 카드 목록에 카드가 한 개 있을 때의 카드 추가 UI는 목록 하단에 노출된다. - [x] 카드 목록에 카드가 여러 개 있을 때의 카드 추가 UI는 상단바에 노출된다. + +## 🚀 3단계 - 페이먼츠(카드사) + +### 구현 기능 목록 +- [x] 리뷰 반영 + - [x] 카드 리스트 프리뷰 파라미터에 실 데이터 추가 + - [x] PaymentCardLayout 추가 + - [x] TextField의 VisualTransformation 사용해서 카드번호, 만료날짜 포맷 개선 + - [x] cardNumber의 뒷자리 8자리 * 처리 +- [x] 카드 목록 화면 구현 + - [x] 카드 추가 화면에 접속했을 때 카드사를 필수로 선택해야 한다. + - [x] 선택한 카드사에 따라 카드 미리보기가 바뀌어야 한다. + - [x] (선택사항) 카드사를 선택할 때 적절한 카드사 아이콘을 노출한다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9996218f..d0fb931f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/nextstep/payments/data/model/CreditCard.kt b/app/src/main/java/nextstep/payments/data/model/CreditCard.kt index b64bd96c..208af2e2 100644 --- a/app/src/main/java/nextstep/payments/data/model/CreditCard.kt +++ b/app/src/main/java/nextstep/payments/data/model/CreditCard.kt @@ -1,9 +1,12 @@ package nextstep.payments.data.model +import nextstep.payments.ui.model.BankType + data class CreditCard ( val cardNumber: String = "", val ownerName: String? = "", - val expiredDate: String = "" + val expiredDate: String = "", + var bank: BankType = BankType.NOT_SELECTED ) diff --git a/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt b/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt index 76c9df15..eb9a9159 100644 --- a/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt +++ b/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt @@ -12,10 +12,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -23,7 +22,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import nextstep.payments.data.model.CreditCard -import nextstep.payments.ui.theme.Black +import nextstep.payments.ui.ext.toFormattedCardNumber +import nextstep.payments.ui.ext.toFormattedDate +import nextstep.payments.ui.model.BankType import nextstep.payments.ui.theme.Yellow @Composable @@ -31,15 +32,9 @@ fun PaymentCard( modifier: Modifier = Modifier, card: CreditCard = CreditCard(), ) { - Box( - contentAlignment = Alignment.CenterStart, - modifier = modifier - .shadow(8.dp) - .size(width = 208.dp, height = 124.dp) - .background( - color = Black, - shape = RoundedCornerShape(5.dp), - ) + PaymentCardLayout( + modifier = modifier, + backgroundColor = card.bank.getCardColorRes() ) { Box( modifier = Modifier @@ -50,35 +45,40 @@ fun PaymentCard( shape = RoundedCornerShape(4.dp), ) ) - if(card.cardNumber.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(start = 14.dp, end = 14.dp, bottom = 16.dp), - verticalArrangement = Arrangement.Bottom, - ) { - val cardTextStyle = TextStyle( - fontSize = 12.sp, - lineHeight = 14.06.sp, - color = Color.White, - letterSpacing = 1.sp - ) - Text( - text = card.cardNumber, - style = cardTextStyle - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 2.dp), - horizontalArrangement = Arrangement.Absolute.SpaceBetween - ) { - Text( - text = card.ownerName ?: "CREW", - style = cardTextStyle - ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 14.dp, end = 14.dp, top = 15.dp, bottom = 16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + val cardTextStyle = TextStyle( + fontSize = 12.sp, + lineHeight = 14.06.sp, + color = Color.White, + letterSpacing = 1.sp + ) + + Text( + text = card.bank.getNameRes()?.let { stringResource(id = it) } ?: "", + style = cardTextStyle + ) + + if (card.cardNumber.isNotEmpty()) { + Column { Text( - text = card.expiredDate, + text = card.cardNumber.toFormattedCardNumber(), style = cardTextStyle ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.Absolute.SpaceBetween + ) { + Text(text = card.ownerName ?: "CREW", style = cardTextStyle) + Text(text = card.expiredDate.toFormattedDate(), style = cardTextStyle) + } } } } @@ -89,15 +89,21 @@ private class PaymentCardPreviewParameters : PreviewParameterProvider = sequenceOf( CreditCard(), CreditCard( - cardNumber = "1234-5678-9011", + cardNumber = "123456789011", ownerName = "kim", - expiredDate = "11/30" + expiredDate = "1130", + bank = BankType.NOT_SELECTED + ), + CreditCard( + cardNumber = "123456789011", + ownerName = "kim", + expiredDate = "1130", + bank = BankType.LOTTE ) ) } - @Preview @Composable fun PaymentCardPreview( diff --git a/app/src/main/java/nextstep/payments/ui/component/PaymentCardLayout.kt b/app/src/main/java/nextstep/payments/ui/component/PaymentCardLayout.kt new file mode 100644 index 00000000..77044b61 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/component/PaymentCardLayout.kt @@ -0,0 +1,38 @@ +package nextstep.payments.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun PaymentCardLayout( + backgroundColor : Color, + modifier: Modifier = Modifier, + onClick : (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .shadow(8.dp) + .size(width = 208.dp, height = 124.dp) + .background( + color = backgroundColor, + shape = RoundedCornerShape(5.dp), + ) + .then( + if(onClick == null) Modifier else Modifier.clickable { onClick() } + ) + ) { + content() + } +} diff --git a/app/src/main/java/nextstep/payments/ui/ext/StringExt.kt b/app/src/main/java/nextstep/payments/ui/ext/StringExt.kt new file mode 100644 index 00000000..78654be3 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/ext/StringExt.kt @@ -0,0 +1,12 @@ +package nextstep.payments.ui.ext + +fun String.toFormattedCardNumber(): String { + return this.chunked(4).joinToString("-") +} + +fun String.toFormattedDate(): String { + if (this.length != 4) return this + val month = this.substring(0, 2) + val year = this.substring(2, 4) + return "$month / $year" +} diff --git a/app/src/main/java/nextstep/payments/ui/list/CardListScreen.kt b/app/src/main/java/nextstep/payments/ui/list/CardListScreen.kt index e45a82c7..2f699851 100644 --- a/app/src/main/java/nextstep/payments/ui/list/CardListScreen.kt +++ b/app/src/main/java/nextstep/payments/ui/list/CardListScreen.kt @@ -36,6 +36,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import nextstep.payments.R import nextstep.payments.data.model.CreditCard import nextstep.payments.ui.component.PaymentCard +import nextstep.payments.ui.component.PaymentCardLayout +import nextstep.payments.ui.model.BankType import nextstep.payments.ui.theme.DarkGrey import nextstep.payments.ui.theme.Grey @@ -163,15 +165,10 @@ fun AddCard( onClick: () -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .size( - width = 208.dp, - height = 124.dp, - ) - .clip(shape = RoundedCornerShape(5.dp)) - .background(color = Grey) - .clickable(onClick = onClick) + PaymentCardLayout( + backgroundColor = Grey, + modifier = modifier, + onClick = onClick ) { Icon( modifier = Modifier @@ -184,18 +181,37 @@ fun AddCard( } } -private val dummyCard = CreditCard( - -) - private class CardListScreenPreviewParameters : PreviewParameterProvider { override val values: Sequence = sequenceOf( CreditCardUiState.Empty, CreditCardUiState.One( - card = dummyCard + card = CreditCard( + cardNumber = "0000-1111-2222-3333", + ownerName = "Kim", + expiredDate = "4/25", + bank = BankType.SHINHAN + ) ), CreditCardUiState.Many( - cards = listOf(dummyCard, dummyCard, dummyCard) + cards = listOf( + CreditCard( + cardNumber = "0000-1111-2222-3333", + ownerName = "Kim", + expiredDate = "04/25", + bank = BankType.SHINHAN + ), + CreditCard( + cardNumber = "0000-1111-2222-3333", + ownerName = "Park", + expiredDate = "04/25", + bank = BankType.KB + ), + CreditCard( + cardNumber = "0000-1111-2222-3333", + ownerName = "Song", + expiredDate = "04/25", + bank = BankType.LOTTE + )) ) ) } diff --git a/app/src/main/java/nextstep/payments/ui/model/BankType.kt b/app/src/main/java/nextstep/payments/ui/model/BankType.kt new file mode 100644 index 00000000..605634d1 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/model/BankType.kt @@ -0,0 +1,59 @@ +package nextstep.payments.ui.model + +import androidx.compose.ui.graphics.Color +import nextstep.payments.R +import nextstep.payments.ui.theme.BCBrandColor +import nextstep.payments.ui.theme.Black +import nextstep.payments.ui.theme.HANABrandColor +import nextstep.payments.ui.theme.HYUNDAIBrandColor +import nextstep.payments.ui.theme.KAKAOBANKBrandColor +import nextstep.payments.ui.theme.KBBrandColor +import nextstep.payments.ui.theme.LOTTEBrandColor +import nextstep.payments.ui.theme.SHINHANBrandColor +import nextstep.payments.ui.theme.WOORIBrandColor + +enum class BankType { + NOT_SELECTED, BC, SHINHAN, KAKAOBANK, HYUNDAI, WOORI, LOTTE, HANA, KB; + + fun getIconRes(): Int? { + return when (this) { + BC -> R.drawable.ic_bc + SHINHAN -> R.drawable.ic_shinhan + KAKAOBANK -> R.drawable.ic_kakaobank + HYUNDAI -> R.drawable.ic_hyundai + WOORI -> R.drawable.ic_woori + LOTTE -> R.drawable.ic_lotte + HANA -> R.drawable.ic_hana + KB -> R.drawable.ic_kb + else -> null + } + } + + fun getNameRes(): Int? { + return when (this) { + BC -> R.string.bc_card + SHINHAN -> R.string.shinhan_card + KAKAOBANK -> R.string.kakao_bank + HYUNDAI -> R.string.hyundai_card + WOORI -> R.string.woori_card + LOTTE -> R.string.lotte_card + HANA -> R.string.hana_card + KB -> R.string.kb_card + else -> null + } + } + + fun getCardColorRes(): Color { + return when(this) { + BC -> BCBrandColor + SHINHAN -> SHINHANBrandColor + KAKAOBANK -> KAKAOBANKBrandColor + HYUNDAI -> HYUNDAIBrandColor + WOORI -> WOORIBrandColor + LOTTE -> LOTTEBrandColor + HANA -> HANABrandColor + KB -> KBBrandColor + else -> Black + } + } +} diff --git a/app/src/main/java/nextstep/payments/ui/newcard/BankBottomSheetContent.kt b/app/src/main/java/nextstep/payments/ui/newcard/BankBottomSheetContent.kt new file mode 100644 index 00000000..79f41aa1 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/newcard/BankBottomSheetContent.kt @@ -0,0 +1,92 @@ +package nextstep.payments.ui.newcard + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import nextstep.payments.ui.model.BankType + + +private const val COLUMN_COUNT = 4 + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun BankBottomSheetContent( + onItemClick: (BankType) -> Unit, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier + .fillMaxWidth() + .height(227.dp) + .padding(vertical = 36.dp), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(23.dp), + maxItemsInEachRow = COLUMN_COUNT, + ) { + BankType.entries.forEach { + if (it == BankType.NOT_SELECTED) return@forEach + BankSelectItem( + bankType = it, + onClick = onItemClick, + ) + } + } +} + +@Composable +fun BankSelectItem( + bankType: BankType, + onClick: (BankType) -> Unit, +) { + Column( + modifier = Modifier + .width(85.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable { onClick(bankType) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(9.dp) + ) { + Image( + painter = painterResource(id = bankType.getIconRes()!!), + contentDescription = "description", + modifier = Modifier + .size(37.dp) + .clip(CircleShape) + .background(Color.Gray) + ) + Text( + text = stringResource(id = bankType.getNameRes()!!), + fontSize = 16.sp, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BankBottomSheetContentPreview() { + BankBottomSheetContent({}) +} + diff --git a/app/src/main/java/nextstep/payments/ui/newcard/NewCardScreen.kt b/app/src/main/java/nextstep/payments/ui/newcard/NewCardScreen.kt index 07c37a9d..73792b3d 100644 --- a/app/src/main/java/nextstep/payments/ui/newcard/NewCardScreen.kt +++ b/app/src/main/java/nextstep/payments/ui/newcard/NewCardScreen.kt @@ -6,25 +6,36 @@ 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.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import nextstep.payments.R +import nextstep.payments.data.model.CreditCard import nextstep.payments.ui.component.PaymentCard +import nextstep.payments.ui.model.BankType import nextstep.payments.ui.theme.PaymentsTheme +import nextstep.payments.ui.utils.CreditCardVisualTransformation +import nextstep.payments.ui.utils.ExpiredDateVisualTransformation // Stateful +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NewCardScreen( viewModel: NewCardViewModel, @@ -36,40 +47,76 @@ internal fun NewCardScreen( val ownerName by viewModel.ownerName.collectAsStateWithLifecycle() val password by viewModel.password.collectAsStateWithLifecycle() val cardAdded by viewModel.cardAdded.collectAsStateWithLifecycle() + val selectedBank by viewModel.selectedBank.collectAsStateWithLifecycle() LaunchedEffect(cardAdded) { if (cardAdded) navigateToCardList() } + + val modalBottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded= true, + confirmValueChange = { false } + ) + + LaunchedEffect(key1 = selectedBank) { + if (selectedBank != BankType.NOT_SELECTED) { + modalBottomSheetState.hide() + } + } + NewCardScreen( cardNumber = cardNumber, expiredDate = expiredDate, ownerName = ownerName, password = password, + selectedBank = selectedBank, setCardNumber = viewModel::setCardNumber, setExpiredDate = viewModel::setExpiredDate, setOwnerName = viewModel::setOwnerName, setPassword = viewModel::setPassword, onBackClick = navigateToCardList, onSaveClick = viewModel::addCard, + bottomSheetState = modalBottomSheetState, + onSelectBank = viewModel::setSelectedBank, modifier = modifier ) } // Stateless +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun NewCardScreen( cardNumber: String, expiredDate: String, ownerName: String, password: String, + selectedBank: BankType, setCardNumber: (String) -> Unit, setExpiredDate: (String) -> Unit, setOwnerName: (String) -> Unit, setPassword: (String) -> Unit, onBackClick: () -> Unit, onSaveClick: () -> Unit, + bottomSheetState: SheetState, + onSelectBank: (BankType) -> Unit, modifier: Modifier = Modifier ) { + if(selectedBank == BankType.NOT_SELECTED) { + ModalBottomSheet( + modifier = modifier, + sheetState = bottomSheetState, + onDismissRequest = { }, + ) { + BankBottomSheetContent( + onItemClick = onSelectBank, + modifier = Modifier + .fillMaxWidth() + .height(297.dp) + .align(Alignment.CenterHorizontally) + ) + } + } + Scaffold( topBar = { NewCardTopBar(onBackClick = onBackClick, onSaveClick = onSaveClick) }, modifier = modifier @@ -83,23 +130,39 @@ private fun NewCardScreen( ) { Spacer(modifier = Modifier.height(14.dp)) - PaymentCard() + PaymentCard( + card = CreditCard().apply { + bank = selectedBank + } + ) Spacer(modifier = Modifier.height(10.dp)) OutlinedTextField( value = cardNumber, - onValueChange = setCardNumber, + onValueChange = { newText -> + if (newText.length <= 16 && newText.all { it.isDigit() }) { + setCardNumber(newText) + } + }, label = { Text(text = stringResource(id = R.string.payment_card_number_label)) }, placeholder = { Text(text = stringResource(id = R.string.payment_card_number_placeholder)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = CreditCardVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = expiredDate, - onValueChange = setExpiredDate, + onValueChange = { newText -> + if (newText.length <= 4 && newText.all { it.isDigit() }) { + setExpiredDate(newText) + } + }, label = { Text(text = stringResource(id = R.string.payment_card_expired_date_label)) }, placeholder = { Text(text = stringResource(id = R.string.payment_card_expired_date_placeholder)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = ExpiredDateVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) @@ -116,45 +179,33 @@ private fun NewCardScreen( onValueChange = setPassword, label = { Text(text = stringResource(id = R.string.payment_card_password_label)) }, placeholder = { Text(text = stringResource(id = R.string.payment_card_password_placeholder)) }, - modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), ) } } } - -@Preview -@Composable -private fun NewCardScreenPreview() { - PaymentsTheme { - NewCardScreen( - viewModel = NewCardViewModel().apply { - setCardNumber("0000 - 0000 - 1111 - 1234") - setExpiredDate("00 / 00") - setOwnerName("김은혜") - setPassword("123123") - }, - navigateToCardList = {} - ) - } -} - +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun StatelessNewCardScreenPreview() { PaymentsTheme { NewCardScreen( - cardNumber = "0000 - 0000 - 1111 - 1234", - expiredDate = "00 / 00", + cardNumber = "1111222233334444", + expiredDate = "0000", ownerName = "김은혜", password = "123123", + selectedBank = BankType.SHINHAN, setCardNumber = {}, setExpiredDate = {}, setOwnerName = {}, setPassword = {}, onBackClick = {}, - onSaveClick = {} + onSaveClick = {}, + bottomSheetState = rememberModalBottomSheetState(), + onSelectBank = {} ) } } diff --git a/app/src/main/java/nextstep/payments/ui/newcard/NewCardViewModel.kt b/app/src/main/java/nextstep/payments/ui/newcard/NewCardViewModel.kt index 03654a66..70d13fb8 100644 --- a/app/src/main/java/nextstep/payments/ui/newcard/NewCardViewModel.kt +++ b/app/src/main/java/nextstep/payments/ui/newcard/NewCardViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import nextstep.payments.data.PaymentCardsRepository import nextstep.payments.data.model.CreditCard +import nextstep.payments.ui.model.BankType class NewCardViewModel( private val repository: PaymentCardsRepository = PaymentCardsRepository @@ -26,6 +27,9 @@ class NewCardViewModel( private val _cardAdded = MutableStateFlow(false) val cardAdded: StateFlow = _cardAdded.asStateFlow() + private val _selectedBank = MutableStateFlow(BankType.NOT_SELECTED) + val selectedBank = _selectedBank.asStateFlow() + fun setCardNumber(cardNumber: String) { _cardNumber.value = cardNumber } @@ -47,7 +51,12 @@ class NewCardViewModel( cardNumber = cardNumber.value, ownerName = ownerName.value, expiredDate = expiredDate.value, + bank = selectedBank.value )) _cardAdded.value = true } + + fun setSelectedBank(bankTypeModel: BankType) { + _selectedBank.value = bankTypeModel + } } diff --git a/app/src/main/java/nextstep/payments/ui/theme/Color.kt b/app/src/main/java/nextstep/payments/ui/theme/Color.kt index 243f334e..60469780 100644 --- a/app/src/main/java/nextstep/payments/ui/theme/Color.kt +++ b/app/src/main/java/nextstep/payments/ui/theme/Color.kt @@ -15,3 +15,13 @@ val Yellow = Color(0xFFCBBA64) val Grey = Color(0xFFE5E5E5) val DarkGrey = Color(0xFF575757) + +// Bank Card Color +val BCBrandColor = Color(0xFFF04651) +val SHINHANBrandColor = Color(0xFF0046ff) +val KAKAOBANKBrandColor = Color(0xFF000000) +val HYUNDAIBrandColor = Color(0xFF575757) +val WOORIBrandColor = Color(0xFF037bc8) +val LOTTEBrandColor = Color(0xFFed1d24) +val HANABrandColor = Color(0xFF009490) +val KBBrandColor = Color(0xFF695F54) diff --git a/app/src/main/java/nextstep/payments/ui/utils/VisualTransformation.kt b/app/src/main/java/nextstep/payments/ui/utils/VisualTransformation.kt new file mode 100644 index 00000000..e49b16bf --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/utils/VisualTransformation.kt @@ -0,0 +1,81 @@ +package nextstep.payments.ui.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class CreditCardVisualTransformation : VisualTransformation { + companion object { + private const val CREDIT_CARD_MAX_LENGTH = 16 + } + + override fun filter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length >= CREDIT_CARD_MAX_LENGTH) text.text.substring(0 until CREDIT_CARD_MAX_LENGTH) else text.text + var out = "" + for (i in trimmed.indices) { + out += trimmed[i] + if (i % 4 == 3 && i != trimmed.lastIndex) out += " - " + } + + // 텍스트를 변환할 때 커서 위치를 올바르게 유지하도록 매핑 + val creditCardOffsetTranslator = object : OffsetMapping { + + // 원본 텍스트의 offset을 변환된 텍스트에 대응하는 위치로 변환 + override fun originalToTransformed(offset: Int): Int { + return when { + offset <= 4 -> offset + offset <= 8 -> offset + 3 + offset <= 12 -> offset + 6 + offset <= CREDIT_CARD_MAX_LENGTH -> offset + 9 + else -> CREDIT_CARD_MAX_LENGTH + 9 + } + } + + // 변환된 텍스트의 offset을 원본 텍스트에 대응하는 위치로 변환 + override fun transformedToOriginal(offset: Int): Int { + return when { + offset <= 3 -> offset + offset <= 11 -> offset - 3 + offset <= 18 -> offset - 6 + offset <= 25 -> offset - 9 + else -> CREDIT_CARD_MAX_LENGTH + } + } + } + + return TransformedText(AnnotatedString(out), creditCardOffsetTranslator) + } +} + +class ExpiredDateVisualTransformation : VisualTransformation { + companion object { + private const val EXPIRED_DATE_MAX_LENGTH = 4 + } + + override fun filter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length >= EXPIRED_DATE_MAX_LENGTH) text.text.substring(0 until EXPIRED_DATE_MAX_LENGTH) else text.text + + var out = "" + for (i in trimmed.indices) { + out += trimmed[i] + if (i == 1) out += " / " + } + + val expirationDateOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 1) return offset + if (offset <= 3) return offset + 3 + return EXPIRED_DATE_MAX_LENGTH + 3 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 2) return offset + if (offset <= 7) return offset - 3 + return EXPIRED_DATE_MAX_LENGTH + } + } + + return TransformedText(AnnotatedString(out), expirationDateOffsetTranslator) + } +} diff --git a/app/src/main/res/drawable/ic_bc.png b/app/src/main/res/drawable/ic_bc.png new file mode 100644 index 00000000..8be5e071 Binary files /dev/null and b/app/src/main/res/drawable/ic_bc.png differ diff --git a/app/src/main/res/drawable/ic_hana.png b/app/src/main/res/drawable/ic_hana.png new file mode 100644 index 00000000..877835b5 Binary files /dev/null and b/app/src/main/res/drawable/ic_hana.png differ diff --git a/app/src/main/res/drawable/ic_hyundai.png b/app/src/main/res/drawable/ic_hyundai.png new file mode 100644 index 00000000..e233d986 Binary files /dev/null and b/app/src/main/res/drawable/ic_hyundai.png differ diff --git a/app/src/main/res/drawable/ic_kakaobank.png b/app/src/main/res/drawable/ic_kakaobank.png new file mode 100644 index 00000000..5d9a99e0 Binary files /dev/null and b/app/src/main/res/drawable/ic_kakaobank.png differ diff --git a/app/src/main/res/drawable/ic_kb.png b/app/src/main/res/drawable/ic_kb.png new file mode 100644 index 00000000..56615e98 Binary files /dev/null and b/app/src/main/res/drawable/ic_kb.png differ diff --git a/app/src/main/res/drawable/ic_lotte.png b/app/src/main/res/drawable/ic_lotte.png new file mode 100644 index 00000000..e9ad1e4d Binary files /dev/null and b/app/src/main/res/drawable/ic_lotte.png differ diff --git a/app/src/main/res/drawable/ic_shinhan.png b/app/src/main/res/drawable/ic_shinhan.png new file mode 100644 index 00000000..423f2929 Binary files /dev/null and b/app/src/main/res/drawable/ic_shinhan.png differ diff --git a/app/src/main/res/drawable/ic_woori.png b/app/src/main/res/drawable/ic_woori.png new file mode 100644 index 00000000..83158b90 Binary files /dev/null and b/app/src/main/res/drawable/ic_woori.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d4ab914..14c677bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,4 +14,13 @@ 비밀번호 0000 + BC카드 + 신한카드 + 카카오뱅크 + 현대카드 + 우리카드 + 롯데카드 + 하나카드 + 국민카드 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee0e55c2..4749626d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.8.4" activityCompose = "1.9.1" composeBom = "2024.06.00" +material = "1.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +27,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" }