diff --git a/README.md b/README.md index 0ed792d9..b727ef93 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,17 @@ - 카드사 목록 아이템 구현 - 카드사 선택 시 해당 카드에 맞게 카드 미리보기 변경 구현 +### STEP-3 Feedback +- 카드사 선택 테스트 코드 추가 +- BottomSheet 자연스럽게 사라지기 +- PaymentCard 프리뷰 보완 +- BankTypeUiModel nullable 개선 + +## STEP-4 +- 카드 수정 기능 구현 + - 카드 목록에서 카드를 선택하면 카드 수정 화면 + - 카드 수정 화면에서 변경사항이 발생하지 않으면 수정이 불가능하게 구현 + - 카드가 수정되면 카드 목록 화면에 변경사항이 반영 + - 카드 수정 시 상단 타이틀 UI + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9996218f..a229b1ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + id("kotlin-parcelize") } android { diff --git a/app/src/androidTest/java/nextstep/payments/ui/ManageCardRouteScreenTest.kt b/app/src/androidTest/java/nextstep/payments/ui/ManageCardRouteScreenTest.kt new file mode 100644 index 00000000..6aa89942 --- /dev/null +++ b/app/src/androidTest/java/nextstep/payments/ui/ManageCardRouteScreenTest.kt @@ -0,0 +1,189 @@ +package nextstep.payments.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import nextstep.payments.data.model.BankType +import nextstep.payments.data.model.CreditCard +import nextstep.payments.screen.model.BankTypeUiModel +import nextstep.payments.screen.cardmanage.ManageCardRouteScreen +import nextstep.payments.screen.cardmanage.ManageCardViewModel +import nextstep.payments.screen.model.arg.CardArgType +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +internal class ManageCardRouteScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + private lateinit var viewModel: ManageCardViewModel + + @Before + fun setUp() { + viewModel = ManageCardViewModel( + savedStateHandle = SavedStateHandle() + ) + } + + @Test + fun 카드가_추가되면_화면_이동_로직이_실행된다() { + var isNavigated = false + + //GIVEN + composeTestRule.setContent { + ManageCardRouteScreen( + navigateToCardList = { + isNavigated = true + }, + viewModel = viewModel + ) + } + viewModel.setBankType(BankTypeUiModel.BC) + viewModel.setCardNumber("1234123412341234") + viewModel.setExpiredDate("1228") + viewModel.setPassword("1234") + + //WHEN + composeTestRule.onNodeWithTag("saveButton").performClick() + + composeTestRule.waitForIdle() + + //THEN + assert(isNavigated) + } + + @Test + fun 새_카드_추가_화면_진입_시_카드사_선택_바텀_시트가_나타난다() { + //GIVEN + composeTestRule.setContent { + ManageCardRouteScreen( + navigateToCardList = { }, + viewModel = viewModel + ) + } + + //THEN + composeTestRule.onNodeWithTag("BankSelectBottomSheet") + .assertIsDisplayed() + } + + @Test + fun 카드사_선택을_하지_않고_뒤로_가기_시_카드_추가_화면에서_빠져나온다() { + //GIVEN + composeTestRule.setContent { + ManageCardRouteScreen( + modifier = Modifier.testTag("ManageCardRouteScreen"), + navigateToCardList = { }, + viewModel = viewModel + ) + } + + //WHEN + composeTestRule.runOnUiThread { + composeTestRule.activity.onBackPressedDispatcher.onBackPressed() + } + + composeTestRule.waitForIdle() + + //THEN + composeTestRule.onNodeWithTag("ManageCardRouteScreen") + .assertDoesNotExist() + } + + @Test + fun 카드사를_선택하면_바텀시트가_내려가고_카드사가_선택되어있다() { + //GIVEN + composeTestRule.setContent { + ManageCardRouteScreen( + modifier = Modifier.testTag("ManageCardRouteScreen"), + navigateToCardList = { }, + viewModel = viewModel + ) + } + + //WHEN + composeTestRule.onNodeWithTag(BankTypeUiModel.BC.name).performClick() + + composeTestRule.waitForIdle() + + //THEN + composeTestRule.onNodeWithTag("BankSelectBottomSheet") + .assertIsNotDisplayed() + assert(viewModel.bankType.value == BankTypeUiModel.BC) + } + + @Test + fun 카드_수정_화면에서_변경사항이_발생하지_않으면_저장_버튼을_클릭할_수_없다() { + //GIVEN + viewModel = ManageCardViewModel( + savedStateHandle = SavedStateHandle().apply { + set(CardArgType.MANAGE_CARD_TYPE_ARG, + CardArgType.EditCardArg( + CreditCard( + cardNumber = "1234123412341234", + expiredDate = "1122", + ownerName = "김컴포즈", + password = "1234", + bankType = BankType.BC + ) + ) + ) + } + ) + composeTestRule.setContent { + ManageCardRouteScreen( + modifier = Modifier.testTag("ManageCardRouteScreen"), + navigateToCardList = { }, + viewModel = viewModel + ) + } + + //THEN + composeTestRule.onNodeWithTag("saveButton").assertIsNotEnabled() + } + + @Test + fun 카드_수정_화면에서_변경사항이_발생하면_저장_버튼을_클릭할_수_있다() { + //GIVEN + viewModel = ManageCardViewModel( + savedStateHandle = SavedStateHandle().apply { + set(CardArgType.MANAGE_CARD_TYPE_ARG, + CardArgType.EditCardArg( + CreditCard( + cardNumber = "1234123412341234", + expiredDate = "1122", + ownerName = "김컴포즈", + password = "1234", + bankType = BankType.BC + ) + ) + ) + } + ) + composeTestRule.setContent { + ManageCardRouteScreen( + modifier = Modifier.testTag("ManageCardRouteScreen"), + navigateToCardList = { }, + viewModel = viewModel + ) + } + + //WHEN + viewModel.setBankType(BankTypeUiModel.HYUNDAI) + + composeTestRule.waitForIdle() + + //THEN + composeTestRule.onNodeWithTag("saveButton").assertIsEnabled() + } +} + diff --git a/app/src/androidTest/java/nextstep/payments/ui/NewCardRouteScreenTest.kt b/app/src/androidTest/java/nextstep/payments/ui/NewCardRouteScreenTest.kt deleted file mode 100644 index fff9608c..00000000 --- a/app/src/androidTest/java/nextstep/payments/ui/NewCardRouteScreenTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package nextstep.payments.ui - -import androidx.activity.ComponentActivity -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import nextstep.payments.screen.model.BankTypeUiModel -import nextstep.payments.screen.newcard.NewCardRouteScreen -import nextstep.payments.screen.newcard.NewCardViewModel -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -internal class NewCardRouteScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - private lateinit var viewModel: NewCardViewModel - - @Before - fun setUp(){ - viewModel = NewCardViewModel() - } - - @Test - fun 카드가_추가되면_화면_이동_로직이_실행된다() { - var isNavigated = false - - //GIVEN - composeTestRule.setContent { - NewCardRouteScreen( - navigateToCardList = { - isNavigated = true - }, - viewModel = viewModel - ) - } - viewModel.setBankType(BankTypeUiModel.BC) - viewModel.setCardNumber("1234123412341234") - viewModel.setExpiredDate("1228") - viewModel.setPassword("1234") - - //WHEN - composeTestRule.onNodeWithTag("saveButton").performClick() - - composeTestRule.waitForIdle() - - //THEN - assert(isNavigated) - } - - @Test - fun 새_카드_추가_화면_진입_시_카드사_선택_바텀_시트가_나타난다(){ - //GIVEN - composeTestRule.setContent { - NewCardRouteScreen( - navigateToCardList = { }, - viewModel = viewModel - ) - } - - //THEN - composeTestRule.onNodeWithTag("BankSelectBottomSheet") - .assertIsDisplayed() - } - - @Test - fun 카드사_선택을_하지_않고_뒤로_가기_시_카드_추가_화면에서_빠져나온다() { - //GIVEN - composeTestRule.setContent { - NewCardRouteScreen( - modifier = Modifier.testTag("NewCardRouteScreen"), - navigateToCardList = { }, - viewModel = viewModel - ) - } - - //WHEN - composeTestRule.runOnUiThread { - composeTestRule.activity.onBackPressedDispatcher.onBackPressed() - } - - composeTestRule.waitForIdle() - - //THEN - composeTestRule.onNodeWithTag("NewCardRouteScreen") - .assertDoesNotExist() - } -} - diff --git a/app/src/androidTest/java/nextstep/payments/ui/PaymentCardTest.kt b/app/src/androidTest/java/nextstep/payments/ui/PaymentCardTest.kt index f7b8f30d..2c7457a8 100644 --- a/app/src/androidTest/java/nextstep/payments/ui/PaymentCardTest.kt +++ b/app/src/androidTest/java/nextstep/payments/ui/PaymentCardTest.kt @@ -28,13 +28,14 @@ internal class PaymentCardTest { month = "", year = "", bankTypeUiModel = BankTypeUiModel.BC - ) + ), + onClick = {} ) } //THEN composeTestRule - .onNodeWithTag("cardNumberText") + .onNodeWithTag("cardNumberText",useUnmergedTree = true) .assertTextContains("1234 - 1234 - **** - ****") } @@ -52,13 +53,14 @@ internal class PaymentCardTest { month = "04", year = "13", bankTypeUiModel = BankTypeUiModel.BC - ) + ), + onClick = {} ) } //THEN composeTestRule - .onNodeWithTag("expiredDateText") + .onNodeWithTag(testTag = "expiredDateText", useUnmergedTree = true) .assertTextContains("04 / 13") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f47641aa..0cb227f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ diff --git a/app/src/main/java/nextstep/payments/MainActivity.kt b/app/src/main/java/nextstep/payments/MainActivity.kt index 5d30c079..943e63b1 100644 --- a/app/src/main/java/nextstep/payments/MainActivity.kt +++ b/app/src/main/java/nextstep/payments/MainActivity.kt @@ -9,7 +9,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import nextstep.payments.screen.cardlist.CardListScreen import nextstep.payments.screen.cardlist.CardListViewModel -import nextstep.payments.screen.newcard.NewCardActivity +import nextstep.payments.screen.cardmanage.ManageCardActivity +import nextstep.payments.screen.model.arg.CardArgType +import nextstep.payments.screen.model.toModel import nextstep.payments.ui.theme.PaymentsTheme class MainActivity : ComponentActivity() { @@ -26,8 +28,20 @@ class MainActivity : ComponentActivity() { PaymentsTheme { CardListScreen( viewModel = viewModel, - navigateToNewCard = { - launcher.launch(Intent(this, NewCardActivity::class.java)) + navigateToAddCard = { + launcher.launch( + Intent(this, ManageCardActivity::class.java) + ) + }, + navigateToEditCard = { card -> + launcher.launch( + Intent(this, ManageCardActivity::class.java).apply { + putExtra( + CardArgType.MANAGE_CARD_TYPE_ARG, + CardArgType.EditCardArg(card.toModel()) + ) + } + ) } ) } diff --git a/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectBottomSheet.kt b/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectBottomSheet.kt index 431ad114..a75d8335 100644 --- a/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectBottomSheet.kt +++ b/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectBottomSheet.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState -import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -23,6 +22,7 @@ import nextstep.payments.ui.theme.PaymentsTheme @Composable fun BankSelectBottomSheet( onBankTypeClick : (BankTypeUiModel) -> Unit, + onDismissRequest : () -> Unit, modalBottomSheetState : SheetState, modifier: Modifier = Modifier, containerColor : Color = Color.White @@ -31,7 +31,7 @@ fun BankSelectBottomSheet( modifier = modifier, sheetState = modalBottomSheetState, containerColor = containerColor, - onDismissRequest = { }, + onDismissRequest = onDismissRequest, ) { BankSelectRow( modifier = Modifier.navigationBarsPadding(), @@ -61,7 +61,8 @@ private fun Preview1() { onBankTypeClick = { bankType -> selectedBank = bankType }, - modalBottomSheetState = modalBottomSheetState + modalBottomSheetState = modalBottomSheetState, + onDismissRequest = {} ) } } diff --git a/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectRow.kt b/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectRow.kt index 4023b711..a28d7966 100644 --- a/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectRow.kt +++ b/app/src/main/java/nextstep/payments/component/bottomsheet/bank/BankSelectRow.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -45,7 +46,7 @@ fun BankSelectRow( bankType = bankType, modifier = Modifier.wrapContentHeight().weight(1f).clickable { onClick(bankType) - } + }.testTag(bankType.name) ) } } diff --git a/app/src/main/java/nextstep/payments/component/card/PaymentCard.kt b/app/src/main/java/nextstep/payments/component/card/PaymentCard.kt index fc93f2eb..a7353486 100644 --- a/app/src/main/java/nextstep/payments/component/card/PaymentCard.kt +++ b/app/src/main/java/nextstep/payments/component/card/PaymentCard.kt @@ -2,7 +2,6 @@ package nextstep.payments.component.card 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.Box import androidx.compose.foundation.layout.BoxScope @@ -20,7 +19,6 @@ 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.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -37,11 +35,13 @@ import nextstep.payments.ui.theme.PaymentsTheme fun PaymentCardFrame( bankType: BankTypeUiModel?, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit = {} ) { CardFrame( modifier = modifier, - backgroundColor = bankType?.color ?: Color(0xFF333333) + backgroundColor = bankType?.color ?: Color(0xFF333333), + onClick = onClick ) { bankType?.let { bankType -> BankTypeRow( @@ -68,22 +68,28 @@ fun PaymentCardFrame( @Composable fun PaymentCard( bankType: BankTypeUiModel?, - modifier: Modifier = Modifier + onClick : () -> Unit, + modifier: Modifier = Modifier, ) { PaymentCardFrame( bankType = bankType, - modifier = modifier + modifier = modifier, + onClick = onClick ) } @Composable fun PaymentCard( card: CreditCardUiModel, + onClick: (CreditCardUiModel) -> Unit, modifier: Modifier = Modifier ) { PaymentCardFrame( modifier = modifier, bankType = card.bankTypeUiModel, + onClick = { + onClick(card) + }, content = { Column( modifier = modifier @@ -227,7 +233,19 @@ private fun Preview4() { month = "12", year = "12", bankTypeUiModel = BankTypeUiModel.BC - ) + ), + onClick = {} + ) + } +} + +@Preview(showBackground = true, name = "PaymentCardWithoutBankType", backgroundColor = 0xFF333333) +@Composable +private fun Preview5() { + PaymentsTheme { + PaymentCard( + bankType = null, + onClick = {}, ) } } diff --git a/app/src/main/java/nextstep/payments/component/textfield/NewCardTextField.kt b/app/src/main/java/nextstep/payments/component/textfield/ManageCardTextField.kt similarity index 100% rename from app/src/main/java/nextstep/payments/component/textfield/NewCardTextField.kt rename to app/src/main/java/nextstep/payments/component/textfield/ManageCardTextField.kt diff --git a/app/src/main/java/nextstep/payments/component/topbar/NewCardTopBar.kt b/app/src/main/java/nextstep/payments/component/topbar/ManageCardTopBar.kt similarity index 76% rename from app/src/main/java/nextstep/payments/component/topbar/NewCardTopBar.kt rename to app/src/main/java/nextstep/payments/component/topbar/ManageCardTopBar.kt index 51049ee7..fdffeb30 100644 --- a/app/src/main/java/nextstep/payments/component/topbar/NewCardTopBar.kt +++ b/app/src/main/java/nextstep/payments/component/topbar/ManageCardTopBar.kt @@ -11,17 +11,26 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import nextstep.payments.screen.model.ManageCardType @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NewCardTopBar( - isAddCardEnabled : Boolean, +fun ManageCardTopBar( + manageCardType: ManageCardType, + isSaveCardEnabled: Boolean, onBackClick: () -> Unit, onSaveClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( - title = { Text("카드 추가") }, + title = { + Text( + text = when (manageCardType) { + ManageCardType.ADD -> "카드 추가" + ManageCardType.EDIT -> "카드 수정" + } + ) + }, navigationIcon = { IconButton(onClick = { onBackClick() }) { Icon( @@ -33,7 +42,7 @@ fun NewCardTopBar( actions = { IconButton( modifier = Modifier.testTag("saveButton"), - enabled = isAddCardEnabled, + enabled = isSaveCardEnabled, onClick = { onSaveClick() } ) { Icon( diff --git a/app/src/main/java/nextstep/payments/data/PaymentCardsRepository.kt b/app/src/main/java/nextstep/payments/data/PaymentCardsRepository.kt index 724eaac3..ba91d388 100644 --- a/app/src/main/java/nextstep/payments/data/PaymentCardsRepository.kt +++ b/app/src/main/java/nextstep/payments/data/PaymentCardsRepository.kt @@ -10,4 +10,14 @@ object PaymentCardsRepository { fun addCard(creditCard: CreditCard) { _creditCards.add(creditCard) } -} \ No newline at end of file + + fun editCard( + currentCard: CreditCard, + updatedCard: CreditCard + ) { + val currentCardIndex = _creditCards.indexOf(currentCard) + if(currentCardIndex == -1) return + + _creditCards[currentCardIndex] = updatedCard + } +} diff --git a/app/src/main/java/nextstep/payments/data/model/BankType.kt b/app/src/main/java/nextstep/payments/data/model/BankType.kt index 0f3b8ad1..94fde409 100644 --- a/app/src/main/java/nextstep/payments/data/model/BankType.kt +++ b/app/src/main/java/nextstep/payments/data/model/BankType.kt @@ -8,5 +8,6 @@ enum class BankType { WOORI, LOTTE, HANA, - KB + KB, + DEFAULT } 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 037a32f1..bce53359 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,13 @@ package nextstep.payments.data.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class CreditCard( val cardNumber: String, val expiredDate: String, val ownerName: String, val password: String, val bankType: BankType -) +) : Parcelable diff --git a/app/src/main/java/nextstep/payments/screen/cardlist/CardListScreen.kt b/app/src/main/java/nextstep/payments/screen/cardlist/CardListScreen.kt index 9df00a73..d25f148e 100644 --- a/app/src/main/java/nextstep/payments/screen/cardlist/CardListScreen.kt +++ b/app/src/main/java/nextstep/payments/screen/cardlist/CardListScreen.kt @@ -25,20 +25,23 @@ import nextstep.payments.component.card.PaymentCard import nextstep.payments.component.topbar.CardListTopBar import nextstep.payments.data.model.BankType import nextstep.payments.data.model.CreditCard +import nextstep.payments.screen.model.CreditCardUiModel import nextstep.payments.screen.model.toUiModel import nextstep.payments.ui.theme.PaymentsTheme @Composable fun CardListScreen( viewModel: CardListViewModel, - navigateToNewCard: () -> Unit, + navigateToAddCard: () -> Unit, + navigateToEditCard: (CreditCardUiModel) -> Unit, modifier: Modifier = Modifier ) { val creditCardUiState by viewModel.cardListUiState.collectAsStateWithLifecycle() CardListScreen( modifier = modifier, - navigateToNewCard = navigateToNewCard, + navigateToAddCard = navigateToAddCard, + navigateToEditCard = navigateToEditCard, creditCardUiState = creditCardUiState ) } @@ -46,7 +49,8 @@ fun CardListScreen( @Composable fun CardListScreen( creditCardUiState: CreditCardUiState, - navigateToNewCard: () -> Unit, + navigateToAddCard: () -> Unit, + navigateToEditCard: (CreditCardUiModel) -> Unit, modifier: Modifier = Modifier ) { Scaffold( @@ -56,7 +60,7 @@ fun CardListScreen( actions = { if(creditCardUiState is CreditCardUiState.Many){ TextButton( - onClick = navigateToNewCard + onClick = navigateToAddCard ) { Text( text = stringResource(id = R.string.card_list_add), @@ -88,7 +92,8 @@ fun CardListScreen( is CreditCardUiState.One -> { item { PaymentCard( - card = creditCardUiState.card + card = creditCardUiState.card, + onClick = navigateToEditCard ) } } @@ -96,7 +101,8 @@ fun CardListScreen( is CreditCardUiState.Many -> { items(creditCardUiState.cards) { card -> PaymentCard( - card = card + card = card, + onClick = navigateToEditCard ) } } @@ -104,7 +110,7 @@ fun CardListScreen( if (creditCardUiState !is CreditCardUiState.Many) { item { AdditionCard( - onClick = navigateToNewCard + onClick = navigateToAddCard ) } } @@ -130,7 +136,8 @@ private fun Preview1() { PaymentsTheme { CardListScreen( viewModel = CardListViewModel(), - navigateToNewCard = {} + navigateToEditCard = {}, + navigateToAddCard = {} ) } } @@ -149,7 +156,8 @@ private fun Preview2() { bankType = BankType.BC ).toUiModel() ), - navigateToNewCard = {} + navigateToEditCard = {}, + navigateToAddCard = {} ) } } @@ -191,7 +199,8 @@ private fun Preview3() { ) ).map { it.toUiModel() } ), - navigateToNewCard = {} + navigateToEditCard = {}, + navigateToAddCard = {} ) } } diff --git a/app/src/main/java/nextstep/payments/screen/newcard/NewCardActivity.kt b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardActivity.kt similarity index 60% rename from app/src/main/java/nextstep/payments/screen/newcard/NewCardActivity.kt rename to app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardActivity.kt index 40c6a705..4117696a 100644 --- a/app/src/main/java/nextstep/payments/screen/newcard/NewCardActivity.kt +++ b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardActivity.kt @@ -1,18 +1,18 @@ -package nextstep.payments.screen.newcard +package nextstep.payments.screen.cardmanage import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import nextstep.payments.ui.theme.PaymentsTheme -class NewCardActivity : ComponentActivity() { +class ManageCardActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { PaymentsTheme { - NewCardRouteScreen( - navigateToCardList = { isAdded -> - if(isAdded == NewCardEvent.Success) setResult(RESULT_OK) + ManageCardRouteScreen( + navigateToCardList = { isChanged -> + if(isChanged == ManageCardEvent.Success) setResult(RESULT_OK) finish() } ) diff --git a/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardEvent.kt b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardEvent.kt new file mode 100644 index 00000000..65457d17 --- /dev/null +++ b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardEvent.kt @@ -0,0 +1,5 @@ +package nextstep.payments.screen.cardmanage + +enum class ManageCardEvent { + Pending, Success, Cancel +} diff --git a/app/src/main/java/nextstep/payments/screen/newcard/NewCardScreen.kt b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardScreen.kt similarity index 59% rename from app/src/main/java/nextstep/payments/screen/newcard/NewCardScreen.kt rename to app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardScreen.kt index 8b8c2c86..e40a19a6 100644 --- a/app/src/main/java/nextstep/payments/screen/newcard/NewCardScreen.kt +++ b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardScreen.kt @@ -1,4 +1,4 @@ -package nextstep.payments.screen.newcard +package nextstep.payments.screen.cardmanage import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -13,6 +13,9 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -20,95 +23,136 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import nextstep.payments.component.bottomsheet.bank.BankSelectBottomSheet import nextstep.payments.component.card.PaymentCard import nextstep.payments.component.textfield.CardNumberTextFiled import nextstep.payments.component.textfield.ExpiredDateTextFiled import nextstep.payments.component.textfield.OwnerNameTextFiled import nextstep.payments.component.textfield.PasswordTextFiled -import nextstep.payments.component.topbar.NewCardTopBar +import nextstep.payments.component.topbar.ManageCardTopBar import nextstep.payments.screen.model.BankTypeUiModel +import nextstep.payments.screen.model.ManageCardType +import nextstep.payments.screen.model.toManageCardType import nextstep.payments.ui.theme.PaymentsTheme @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun NewCardRouteScreen( +internal fun ManageCardRouteScreen( modifier: Modifier = Modifier, - navigateToCardList: (NewCardEvent) -> Unit, - viewModel: NewCardViewModel = viewModel(), + navigateToCardList: (ManageCardEvent) -> Unit, + viewModel: ManageCardViewModel = viewModel(), ) { + val manageCardType by viewModel.cardArgType + .map { it.toManageCardType() }.collectAsStateWithLifecycle(ManageCardType.ADD) val cardNumber by viewModel.cardNumber.collectAsStateWithLifecycle() val expiredDate by viewModel.expiredDate.collectAsStateWithLifecycle() val ownerName by viewModel.ownerName.collectAsStateWithLifecycle() val password by viewModel.password.collectAsStateWithLifecycle() val bankType by viewModel.bankType.collectAsStateWithLifecycle() - val cardAdded by viewModel.cardAdded.collectAsStateWithLifecycle() - val isAddCardEnabled by viewModel.isAddCardEnabled.collectAsStateWithLifecycle() + val cardChanged by viewModel.cardChanged.collectAsStateWithLifecycle() + val isSaveCardEnabled by viewModel.isSaveCardEnabled.collectAsStateWithLifecycle() val modalBottomSheetState = rememberModalBottomSheetState( - confirmValueChange = { false } + confirmValueChange = { + bankType != null + } ) + var shouldHideBottomSheet by remember { mutableStateOf(false) } + var isShownBottomSheet by remember { mutableStateOf(true) } - LaunchedEffect(key1 = bankType) { - if (bankType != null) { - modalBottomSheetState.hide() + LaunchedEffect(key1 = cardChanged) { + if (cardChanged != ManageCardEvent.Pending) { + navigateToCardList(cardChanged) } } LaunchedEffect(modalBottomSheetState.targetValue) { - if (modalBottomSheetState.hasExpandedState && modalBottomSheetState.targetValue == SheetValue.Hidden && bankType == null) { - viewModel.cancelToAddCard() + if (modalBottomSheetState.hasExpandedState && modalBottomSheetState.targetValue == SheetValue.Hidden) { + if( bankType == null) { + viewModel.cancelToChangeCard() + } + else { + shouldHideBottomSheet = true + } + } + } + + LaunchedEffect(key1 = cardChanged) { + if (cardChanged != ManageCardEvent.Pending) { + navigateToCardList(cardChanged) } } - LaunchedEffect(key1 = cardAdded) { - if (cardAdded != NewCardEvent.Pending) { - navigateToCardList(cardAdded) + LaunchedEffect(key1 = bankType) { + if (bankType != null) { + shouldHideBottomSheet = true } } - if (bankType == null) { + LaunchedEffect(shouldHideBottomSheet) { + if(shouldHideBottomSheet){ + launch { + modalBottomSheetState.hide() + }.invokeOnCompletion { + isShownBottomSheet = false + shouldHideBottomSheet = false + } + } + } + + if (isShownBottomSheet) { BankSelectBottomSheet( onBankTypeClick = viewModel::setBankType, modalBottomSheetState = modalBottomSheetState, + onDismissRequest = { + shouldHideBottomSheet = true + }, modifier = Modifier.testTag("BankSelectBottomSheet") ) } - NewCardScreen( + ManageCardScreen( modifier = modifier, + manageCardType = manageCardType, cardNumber = cardNumber, expiredDate = expiredDate, ownerName = ownerName, password = password, bankType = bankType, - isAddCardEnabled = isAddCardEnabled, + isSaveCardEnabled = isSaveCardEnabled, setCardNumber = viewModel::setCardNumber, setExpiredDate = viewModel::setExpiredDate, setOwnerName = viewModel::setOwnerName, setPassword = viewModel::setPassword, + onCardClick = { + isShownBottomSheet = true + }, onBackClick = { - viewModel.cancelToAddCard() + viewModel.cancelToChangeCard() }, onSaveClick = { - viewModel.addCard() + viewModel.saveCard() } ) } @Composable -internal fun NewCardScreen( +internal fun ManageCardScreen( + manageCardType : ManageCardType, cardNumber: String, expiredDate: String, ownerName: String, password: String, bankType: BankTypeUiModel?, - isAddCardEnabled : Boolean, + isSaveCardEnabled : Boolean, setCardNumber: (String) -> Unit, setExpiredDate: (String) -> Unit, setOwnerName: (String) -> Unit, setPassword: (String) -> Unit, + onCardClick : () -> Unit, onBackClick: () -> Unit, onSaveClick: () -> Unit, modifier: Modifier = Modifier @@ -116,8 +160,9 @@ internal fun NewCardScreen( Scaffold( topBar = { - NewCardTopBar( - isAddCardEnabled = isAddCardEnabled, + ManageCardTopBar( + manageCardType = manageCardType, + isSaveCardEnabled = isSaveCardEnabled, onBackClick = onBackClick, onSaveClick = onSaveClick ) @@ -134,7 +179,8 @@ internal fun NewCardScreen( Spacer(modifier = Modifier.height(14.dp)) PaymentCard( - bankType = bankType + bankType = bankType, + onClick = onCardClick ) Spacer(modifier = Modifier.height(10.dp)) @@ -167,21 +213,47 @@ internal fun NewCardScreen( } -@Preview +@Preview(showBackground = true, name = "AddCardScreenPreview") +@Composable +private fun Preview1() { + PaymentsTheme { + ManageCardScreen( + manageCardType = ManageCardType.ADD, + cardNumber = "0000000000000000", + expiredDate = "1123", + ownerName = "김", + password = "1234", + bankType = BankTypeUiModel.BC, + isSaveCardEnabled = true, + setCardNumber = {}, + setExpiredDate = {}, + setOwnerName = {}, + setPassword = {}, + onCardClick = {}, + onBackClick = {}, + onSaveClick = {} + ) + } +} + + +@Preview(showBackground = true, name = "EditCardScreenPreview") @Composable -private fun NewCardScreenPreview() { +private fun Preview2() { PaymentsTheme { - NewCardScreen( + ManageCardScreen( + manageCardType = ManageCardType.EDIT, cardNumber = "0000000000000000", expiredDate = "1123", ownerName = "김", password = "1234", bankType = BankTypeUiModel.BC, - isAddCardEnabled = true, + isSaveCardEnabled = true, setCardNumber = {}, setExpiredDate = {}, setOwnerName = {}, setPassword = {}, + onCardClick = {}, onBackClick = {}, onSaveClick = {} ) diff --git a/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardViewModel.kt b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardViewModel.kt new file mode 100644 index 00000000..022af180 --- /dev/null +++ b/app/src/main/java/nextstep/payments/screen/cardmanage/ManageCardViewModel.kt @@ -0,0 +1,140 @@ +package nextstep.payments.screen.cardmanage + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import nextstep.payments.data.PaymentCardsRepository +import nextstep.payments.data.model.CreditCard +import nextstep.payments.screen.model.BankTypeUiModel +import nextstep.payments.screen.model.arg.CardArgType +import nextstep.payments.screen.model.toEntity +import nextstep.payments.screen.model.toUiModel + +class ManageCardViewModel( + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val cardArgType = savedStateHandle.getStateFlow( + CardArgType.MANAGE_CARD_TYPE_ARG, + CardArgType.AddCardArg + ) + + private val _cardNumber = MutableStateFlow(cardArgType.value.creditCardToEdit?.cardNumber ?: "") + val cardNumber: StateFlow = _cardNumber.asStateFlow() + + private val _expiredDate = + MutableStateFlow(cardArgType.value.creditCardToEdit?.expiredDate ?: "") + val expiredDate: StateFlow = _expiredDate.asStateFlow() + + private val _ownerName = MutableStateFlow(cardArgType.value.creditCardToEdit?.ownerName ?: "") + val ownerName: StateFlow = _ownerName.asStateFlow() + + private val _password = MutableStateFlow(cardArgType.value.creditCardToEdit?.password ?: "") + val password: StateFlow = _password.asStateFlow() + + private val _bankType = MutableStateFlow(cardArgType.value.creditCardToEdit?.bankType?.toUiModel()) + val bankType: StateFlow = _bankType.asStateFlow() + + val isSaveCardEnabled: StateFlow = + combine( + cardNumber, + expiredDate, + ownerName, + password, + bankType + ) { cardNumber, expiredDate, ownerName, password, bankType -> + cardArgType.value.creditCardToEdit?.let { creditCard -> + if(isNotCardChanged(creditCard, cardNumber, expiredDate, ownerName, password, bankType)) + return@combine false + } + isValidInputs(cardNumber, expiredDate, password, bankType) + }.stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(500) + ) + + private fun isNotCardChanged( + creditCard: CreditCard, + cardNumber: String, + expiredDate: String, + ownerName: String, + password: String, + bankType: BankTypeUiModel? + ) = creditCard.cardNumber == cardNumber && + creditCard.expiredDate == expiredDate && + creditCard.ownerName == ownerName && + creditCard.password == password && + creditCard.bankType.toUiModel() == bankType + + private fun isValidInputs( + cardNumber: String, + expiredDate: String, + password: String, + bankType: BankTypeUiModel? + ) = cardNumber.length == 16 && expiredDate.length == 4 && password.length == 4 && bankType != null + + + private val _cardChanged = MutableStateFlow(ManageCardEvent.Pending) + val cardChanged: StateFlow = _cardChanged.asStateFlow() + + fun setCardNumber(cardNumber: String) { + if (cardNumber.length > 16) return + _cardNumber.value = cardNumber + } + + fun setExpiredDate(expiredDate: String) { + if (expiredDate.length > 4) return + _expiredDate.value = expiredDate + } + + fun setOwnerName(ownerName: String) { + _ownerName.value = ownerName + } + + fun setPassword(password: String) { + if (password.length > 4) return + _password.value = password + } + + fun setBankType(bankTypeUiModel: BankTypeUiModel) { + _bankType.value = bankTypeUiModel + } + + fun saveCard() { + val selectedBankType = bankType.value?.toEntity() ?: return + + val creditCard = CreditCard( + cardNumber = cardNumber.value, + expiredDate = expiredDate.value, + ownerName = ownerName.value, + password = password.value, + bankType = selectedBankType + ) + + when(cardArgType.value){ + is CardArgType.AddCardArg -> { + PaymentCardsRepository.addCard(creditCard) + } + is CardArgType.EditCardArg -> { + cardArgType.value.creditCardToEdit?.let { currentCard -> + PaymentCardsRepository.editCard(currentCard, creditCard) + } + + } + } + + _cardChanged.value = ManageCardEvent.Success + } + + + fun cancelToChangeCard() { + _cardChanged.value = ManageCardEvent.Cancel + } +} diff --git a/app/src/main/java/nextstep/payments/screen/model/BankTypeUiModel.kt b/app/src/main/java/nextstep/payments/screen/model/BankTypeUiModel.kt index 8b31a79f..1260bf43 100644 --- a/app/src/main/java/nextstep/payments/screen/model/BankTypeUiModel.kt +++ b/app/src/main/java/nextstep/payments/screen/model/BankTypeUiModel.kt @@ -57,8 +57,8 @@ fun BankType.toUiModel() : BankTypeUiModel? { return BankTypeUiModel.entries.find { it.name == this.name } } -fun BankTypeUiModel.toEntity() : BankType? { - return BankType.entries.find { it.name == this.name } +fun BankTypeUiModel?.toEntity() : BankType { + return BankType.entries.find { it.name == this?.name } ?: BankType.DEFAULT } diff --git a/app/src/main/java/nextstep/payments/screen/model/CreditCardUiModel.kt b/app/src/main/java/nextstep/payments/screen/model/CreditCardUiModel.kt index 2fbecb04..3c06fa53 100644 --- a/app/src/main/java/nextstep/payments/screen/model/CreditCardUiModel.kt +++ b/app/src/main/java/nextstep/payments/screen/model/CreditCardUiModel.kt @@ -41,3 +41,11 @@ fun CreditCard.toUiModel() = bankTypeUiModel = bankType.toUiModel() ) +fun CreditCardUiModel.toModel() + = CreditCard( + cardNumber = cardNumber, + ownerName = ownerName, + password = password, + bankType = bankTypeUiModel.toEntity(), + expiredDate = month + year + ) diff --git a/app/src/main/java/nextstep/payments/screen/model/ManageCardType.kt b/app/src/main/java/nextstep/payments/screen/model/ManageCardType.kt new file mode 100644 index 00000000..72afee5a --- /dev/null +++ b/app/src/main/java/nextstep/payments/screen/model/ManageCardType.kt @@ -0,0 +1,15 @@ +package nextstep.payments.screen.model + +import nextstep.payments.screen.model.arg.CardArgType + +enum class ManageCardType { + ADD, + EDIT +} + +fun CardArgType.toManageCardType() : ManageCardType + = when(this){ + is CardArgType.AddCardArg -> ManageCardType.ADD + is CardArgType.EditCardArg -> ManageCardType.EDIT + } + diff --git a/app/src/main/java/nextstep/payments/screen/model/arg/CardArgType.kt b/app/src/main/java/nextstep/payments/screen/model/arg/CardArgType.kt new file mode 100644 index 00000000..e5404f36 --- /dev/null +++ b/app/src/main/java/nextstep/payments/screen/model/arg/CardArgType.kt @@ -0,0 +1,23 @@ +package nextstep.payments.screen.model.arg + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import nextstep.payments.data.model.CreditCard + +sealed class CardArgType : Parcelable { + @Parcelize + data object AddCardArg : CardArgType() + @Parcelize + data class EditCardArg(val creditCard: CreditCard) : CardArgType() + + val creditCardToEdit : CreditCard? by lazy { + when(this){ + is EditCardArg -> this.creditCard + else -> null + } + } + + companion object { + const val MANAGE_CARD_TYPE_ARG = "manageCardTypeArg" + } +} diff --git a/app/src/main/java/nextstep/payments/screen/newcard/NewCardEvent.kt b/app/src/main/java/nextstep/payments/screen/newcard/NewCardEvent.kt deleted file mode 100644 index 1a627242..00000000 --- a/app/src/main/java/nextstep/payments/screen/newcard/NewCardEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package nextstep.payments.screen.newcard - -enum class NewCardEvent { - Pending, Success, Cancel -} \ No newline at end of file diff --git a/app/src/main/java/nextstep/payments/screen/newcard/NewCardViewModel.kt b/app/src/main/java/nextstep/payments/screen/newcard/NewCardViewModel.kt deleted file mode 100644 index 25232575..00000000 --- a/app/src/main/java/nextstep/payments/screen/newcard/NewCardViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package nextstep.payments.screen.newcard - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.stateIn -import nextstep.payments.data.PaymentCardsRepository -import nextstep.payments.data.model.CreditCard -import nextstep.payments.screen.model.BankTypeUiModel -import nextstep.payments.screen.model.toEntity - -class NewCardViewModel : ViewModel() { - - private val _cardNumber = MutableStateFlow("") - val cardNumber: StateFlow = _cardNumber.asStateFlow() - - private val _expiredDate = MutableStateFlow("") - val expiredDate: StateFlow = _expiredDate.asStateFlow() - - private val _ownerName = MutableStateFlow("") - val ownerName: StateFlow = _ownerName.asStateFlow() - - private val _password = MutableStateFlow("") - val password: StateFlow = _password.asStateFlow() - - private val _bankType = MutableStateFlow(null) - val bankType : StateFlow = _bankType.asStateFlow() - - val isAddCardEnabled : StateFlow = - combine( - cardNumber, - expiredDate, - password, - bankType - ){ cardNumber, expiredDate, password, bankType -> - cardNumber.length == 16 && expiredDate.length == 4 && password.length == 4 && bankType != null - }.stateIn( - scope = viewModelScope, - initialValue = false, - started = SharingStarted.WhileSubscribed(500) - ) - - private val _cardAdded = MutableStateFlow(NewCardEvent.Pending) - val cardAdded : StateFlow = _cardAdded.asStateFlow() - - fun setCardNumber(cardNumber: String) { - if(cardNumber.length > 16) return - _cardNumber.value = cardNumber - } - - fun setExpiredDate(expiredDate: String) { - if(expiredDate.length > 4) return - _expiredDate.value = expiredDate - } - - fun setOwnerName(ownerName: String) { - _ownerName.value = ownerName - } - - fun setPassword(password: String) { - if(password.length > 4) return - _password.value = password - } - - fun setBankType(bankTypeUiModel: BankTypeUiModel){ - _bankType.value = bankTypeUiModel - } - - fun addCard(){ - val selectedBankType = bankType.value?.toEntity() ?: return - - PaymentCardsRepository.addCard( - CreditCard( - cardNumber = cardNumber.value, - expiredDate = expiredDate.value, - ownerName = ownerName.value, - password = password.value, - bankType = selectedBankType - ) - ) - _cardAdded.value = NewCardEvent.Success - } - - fun cancelToAddCard(){ - _cardAdded.value = NewCardEvent.Cancel - } -}