diff --git a/README.md b/README.md index 531ad59e..c5a15e09 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,9 @@ - 카드 추가 화면 - [x] 접속시 카드사 선택 bottom sheet 보여주기 - [x] 카드 미리 보기에 카드사 정보 표시하기 + +## Step4 + +- [x] 카드 목록에서 카드를 선택하면 카드 수정 화면으로 이동한다. +- [x] 카드 수정 화면에서 변경사항이 발생하지 않으면 수정이 불가능하다. +- [x] 카드가 수정되면 카드 목록 화면에 변경사항이 반영된다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c415be0..c273c1b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.compose.compiler) + alias(libs.plugins.parcelize) } android { diff --git a/app/src/androidTest/java/nextstep/payments/ui/card_list/CardListScreenTest.kt b/app/src/androidTest/java/nextstep/payments/ui/card_list/CardListScreenTest.kt index 30e84eb8..d084caff 100644 --- a/app/src/androidTest/java/nextstep/payments/ui/card_list/CardListScreenTest.kt +++ b/app/src/androidTest/java/nextstep/payments/ui/card_list/CardListScreenTest.kt @@ -19,6 +19,7 @@ class CardListScreenTest { cards = CreditCardUiState.Empty ), navigateToNewCard = {}, + navigateToEditCard = {}, ) } @@ -41,6 +42,7 @@ class CardListScreenTest { ) ), navigateToNewCard = {}, + navigateToEditCard = {}, ) } @@ -70,6 +72,7 @@ class CardListScreenTest { ) ), navigateToNewCard = {}, + navigateToEditCard = {}, ) } diff --git a/app/src/main/java/nextstep/payments/MainActivity.kt b/app/src/main/java/nextstep/payments/MainActivity.kt index db61ac46..a2efd838 100644 --- a/app/src/main/java/nextstep/payments/MainActivity.kt +++ b/app/src/main/java/nextstep/payments/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.viewModels import nextstep.payments.ui.card_list.CardListScreenRoot import nextstep.payments.ui.card_list.CardListViewModel import nextstep.payments.ui.new_card.NewCardActivity +import nextstep.payments.ui.new_card.NewCardViewModel import nextstep.payments.ui.theme.PaymentsTheme class MainActivity : ComponentActivity() { @@ -33,7 +34,13 @@ class MainActivity : ComponentActivity() { navigateToNewCard = { val intent = Intent(this, NewCardActivity::class.java) launcher.launch(intent) - } + }, + navigateToEditCard = { + val intent = Intent(this, NewCardActivity::class.java).apply { + putExtra(NewCardViewModel.CARD_INFO_KEY, it) + } + launcher.launch(intent) + }, ) } } diff --git a/app/src/main/java/nextstep/payments/data/card/CardEntity.kt b/app/src/main/java/nextstep/payments/data/card/CardEntity.kt index 5ea9ca58..d7dde8fb 100644 --- a/app/src/main/java/nextstep/payments/data/card/CardEntity.kt +++ b/app/src/main/java/nextstep/payments/data/card/CardEntity.kt @@ -1,6 +1,9 @@ package nextstep.payments.data.card +import java.util.UUID + data class CardEntity( + val id: UUID = UUID.randomUUID(), val cardNumber: String = "", val expiredDate: String = "", val ownerName: String = "", diff --git a/app/src/main/java/nextstep/payments/data/card/PaymentCardRepository.kt b/app/src/main/java/nextstep/payments/data/card/PaymentCardRepository.kt index 4b7a1a8f..d8703cea 100644 --- a/app/src/main/java/nextstep/payments/data/card/PaymentCardRepository.kt +++ b/app/src/main/java/nextstep/payments/data/card/PaymentCardRepository.kt @@ -1,18 +1,49 @@ package nextstep.payments.data.card +import java.util.UUID + object PaymentCardRepository { - private val _cards = mutableListOf() + private val cardById = linkedMapOf() val cards: List - get() = _cards.toList() + get() = cardById.values.toList() + + private val idByCardNumber = hashMapOf() - fun addCard(cardEntity: CardEntity): Boolean { + /** + * 전달된 uuid가 이미 사용 중이라면 사용 중이지 않은 uuid로 변경해서 등록합니다. + */ + fun addCard(card: CardEntity): Boolean { // 카드를 등록하기 전에 이미 등록된 카드 번호인지 확인합니다. - // LazyList에서 key를 카드번호로 설정해뒀는데, 중복된 key를 가진 데이터가 있으면 에러가 발생해서 추가했습니다. - if (_cards.any { it.cardNumber == cardEntity.cardNumber }) { + if (card.cardNumber in idByCardNumber) { return false } - _cards.add(cardEntity) + var id = card.id + while (id in cardById) { + id = UUID.randomUUID() + } + + cardById[id] = card.copy(id = id) + idByCardNumber[card.cardNumber] = id + return true + } + + fun editCard(id: UUID, newCard: CardEntity): Boolean { + val oldCard = cardById[id] ?: return false + + if (newCard.cardNumber != oldCard.cardNumber) { + if (newCard.cardNumber in idByCardNumber) { + return false + } + idByCardNumber.remove(oldCard.cardNumber) + idByCardNumber[newCard.cardNumber] = id + } + cardById[id] = newCard.copy(id = id) return true } + + fun getPassword(id: UUID): String { + return cardById[id]?.password ?: "" + } + } diff --git a/app/src/main/java/nextstep/payments/ui/card_list/CardListScreen.kt b/app/src/main/java/nextstep/payments/ui/card_list/CardListScreen.kt index 27aec90c..2b71c9cf 100644 --- a/app/src/main/java/nextstep/payments/ui/card_list/CardListScreen.kt +++ b/app/src/main/java/nextstep/payments/ui/card_list/CardListScreen.kt @@ -41,6 +41,7 @@ import nextstep.payments.ui.theme.PaymentsTheme fun CardListScreenRoot( viewModel: CardListViewModel, navigateToNewCard: () -> Unit, + navigateToEditCard: (CreditCard) -> Unit, modifier: Modifier = Modifier, ) { val state = viewModel.state.collectAsStateWithLifecycle() @@ -48,6 +49,7 @@ fun CardListScreenRoot( CardListScreen( state = state.value, navigateToNewCard = navigateToNewCard, + navigateToEditCard = navigateToEditCard, modifier = modifier, ) } @@ -56,6 +58,7 @@ fun CardListScreenRoot( internal fun CardListScreen( state: CardListState, navigateToNewCard: () -> Unit, + navigateToEditCard: (CreditCard) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -110,6 +113,7 @@ internal fun CardListScreen( is CreditCardUiState.Many -> { ManyCardScreen( state = cards, + navigateToEditCard = navigateToEditCard, modifier = Modifier.padding(innerPadding), ) } @@ -118,6 +122,7 @@ internal fun CardListScreen( OneCardScreen( state = cards, newCardAddContent = paymentCardAdd, + navigateToEditCard = navigateToEditCard, modifier = Modifier.padding(innerPadding), ) } @@ -152,6 +157,7 @@ private fun EmptyCardScreen( @Composable private fun ManyCardScreen( state: CreditCardUiState.Many, + navigateToEditCard: (CreditCard) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -167,6 +173,9 @@ private fun ManyCardScreen( } ) { PaymentCard( + modifier = Modifier.clickable { + navigateToEditCard(it) + }, cardInfo = it, ) } @@ -177,6 +186,7 @@ private fun ManyCardScreen( fun OneCardScreen( state: CreditCardUiState.One, newCardAddContent: @Composable () -> Unit, + navigateToEditCard: (CreditCard) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -185,6 +195,9 @@ fun OneCardScreen( .fillMaxSize(), ) { PaymentCard( + modifier = Modifier.clickable { + navigateToEditCard(state.creditCard) + }, cardInfo = state.creditCard ) Spacer(modifier = Modifier.padding(bottom = 32.dp)) @@ -240,6 +253,7 @@ private fun CardListScreenPreview( CardListScreen( state = state, navigateToNewCard = {}, + navigateToEditCard = {}, ) } } diff --git a/app/src/main/java/nextstep/payments/ui/common/model/CreditCard.kt b/app/src/main/java/nextstep/payments/ui/common/model/CreditCard.kt index f69fc5fd..f51e4c74 100644 --- a/app/src/main/java/nextstep/payments/ui/common/model/CreditCard.kt +++ b/app/src/main/java/nextstep/payments/ui/common/model/CreditCard.kt @@ -1,17 +1,19 @@ package nextstep.payments.ui.common.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import nextstep.payments.ui.new_card.CardType +import java.util.UUID import kotlin.text.filter -/** - * 이 클래스가 common/model 하위에 있는데, 더 적절한 위치가 어디일지 궁금합니다. - */ +@Parcelize data class CreditCard( + val id: UUID = UUID.randomUUID(), val cardNumber: String = "", val expiredDate: String = "", val ownerName: String = "", val company: CardType = CardType.NOT_SELECTED, -) { +): Parcelable { fun formatExpiredDate(): String { val groups = expiredDate.filter { it.isDigit() }.chunked(2) return groups.joinToString(" / ") diff --git a/app/src/main/java/nextstep/payments/ui/mapper/CardMapper.kt b/app/src/main/java/nextstep/payments/ui/mapper/CardMapper.kt index 5d753079..456e5159 100644 --- a/app/src/main/java/nextstep/payments/ui/mapper/CardMapper.kt +++ b/app/src/main/java/nextstep/payments/ui/mapper/CardMapper.kt @@ -7,6 +7,7 @@ import nextstep.payments.ui.new_card.CardType fun CardEntity.toUi(): CreditCard = CreditCard( + id = id, cardNumber = cardNumber, expiredDate = expiredDate, ownerName = ownerName, @@ -40,3 +41,4 @@ fun CardTypeEntity.toUi(): CardType { CardTypeEntity.KB -> CardType.KB } } + diff --git a/app/src/main/java/nextstep/payments/ui/new_card/CardType.kt b/app/src/main/java/nextstep/payments/ui/new_card/CardType.kt index 03b50dbb..b999d5b6 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/CardType.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/CardType.kt @@ -1,14 +1,17 @@ package nextstep.payments.ui.new_card +import android.os.Parcelable import androidx.annotation.ColorInt import androidx.annotation.DrawableRes +import kotlinx.parcelize.Parcelize import nextstep.payments.R +@Parcelize enum class CardType( val companyName: String, @param:DrawableRes val imageResource: Int, @param:ColorInt val color: Int, -) { +): Parcelable { NOT_SELECTED("", 0, 0xFF333333.toInt()), BC("BC카드", R.drawable.bc, 0xFFF04651.toInt()), SHINHAN("신한카드", R.drawable.shinhan, 0xFF0046FF.toInt()), diff --git a/app/src/main/java/nextstep/payments/ui/new_card/NewCardEvent.kt b/app/src/main/java/nextstep/payments/ui/new_card/NewCardEvent.kt index d420b6ae..d165d9ae 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/NewCardEvent.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/NewCardEvent.kt @@ -4,5 +4,7 @@ sealed interface NewCardEvent { data object CardAddSuccess : NewCardEvent data object CardAddFail : NewCardEvent + data object CardEditSuccess : NewCardEvent + data object CardEditFail : NewCardEvent data object NavigateBack : NewCardEvent } diff --git a/app/src/main/java/nextstep/payments/ui/new_card/NewCardScreen.kt b/app/src/main/java/nextstep/payments/ui/new_card/NewCardScreen.kt index d372d71c..fe6940be 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/NewCardScreen.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/NewCardScreen.kt @@ -61,7 +61,7 @@ fun NewCardScreenRoot( ObserveAsEvents(viewModel.events) { event -> when (event) { - NewCardEvent.CardAddSuccess -> { + NewCardEvent.CardAddSuccess, NewCardEvent.CardEditSuccess, NewCardEvent.NavigateBack -> { navigateToCardList() } @@ -73,15 +73,27 @@ fun NewCardScreenRoot( ).show() } - NewCardEvent.NavigateBack -> { - navigateToCardList() + NewCardEvent.CardEditFail -> { + Toast.makeText( + context, + context.getString(R.string.card_list_edit_card_fail), + Toast.LENGTH_SHORT + ).show() } } } val isAddEnabled by remember { derivedStateOf { - state.isValid(CardInputValidator) + !state.isCardValueSame(viewModel.originalState) && state.isValid(CardInputValidator) + } + } + + val topBarTitle = remember { + if (viewModel.originalState == NewCardState.EMPTY) { + context.getString(R.string.new_card_top_bar_title) + } else { + context.getString(R.string.edit_card_top_bar_title) } } @@ -89,6 +101,7 @@ fun NewCardScreenRoot( state = state, isAddEnabled = isAddEnabled, onAction = viewModel::onAction, + topBarTitle = topBarTitle, modifier = modifier, ) } @@ -98,6 +111,7 @@ internal fun NewCardScreen( state: NewCardState, isAddEnabled: Boolean, onAction: (NewCardAction) -> Unit, + topBarTitle: String, modifier: Modifier = Modifier, ) { val cardNumberTransformation = remember { @@ -116,6 +130,7 @@ internal fun NewCardScreen( onSaveClick = { onAction(NewCardAction.OnAddCardClick) }, + title = topBarTitle, isAddEnabled = isAddEnabled, ) }, @@ -291,6 +306,7 @@ private fun NewCardScreenPreview() { showBottomSheet = false, ), isAddEnabled = true, + topBarTitle = "카드 추가", onAction = { }, ) } diff --git a/app/src/main/java/nextstep/payments/ui/new_card/NewCardState.kt b/app/src/main/java/nextstep/payments/ui/new_card/NewCardState.kt index d807fb93..febeef8b 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/NewCardState.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/NewCardState.kt @@ -1,5 +1,7 @@ package nextstep.payments.ui.new_card +import java.util.UUID + data class NewCardState( val cardNumber: String = "", val expiredDate: String = "", @@ -7,6 +9,7 @@ data class NewCardState( val password: String = "", val showBottomSheet: Boolean = true, val cardType: CardType = CardType.NOT_SELECTED, + val id: UUID = UUID.randomUUID(), ) { fun isValid(cardInputValidator: CardInputValidator): Boolean { return cardInputValidator.isCardNumberValid(cardNumber) && @@ -14,4 +17,14 @@ data class NewCardState( cardInputValidator.isCardOwnerNameValid(ownerName) && cardInputValidator.isPasswordValid(password) } + + fun isCardValueSame(other: NewCardState): Boolean { + return cardNumber == other.cardNumber && expiredDate == other.expiredDate + && ownerName == other.ownerName && password == other.password + && cardType == other.cardType + } + + companion object { + val EMPTY = NewCardState() + } } diff --git a/app/src/main/java/nextstep/payments/ui/new_card/NewCardTopBar.kt b/app/src/main/java/nextstep/payments/ui/new_card/NewCardTopBar.kt index a3462748..16168228 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/NewCardTopBar.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/NewCardTopBar.kt @@ -21,10 +21,11 @@ fun NewCardTopBar( onBackClick: () -> Unit, onSaveClick: () -> Unit, isAddEnabled: Boolean, + title: String, modifier: Modifier = Modifier, ) { TopAppBar( - title = { Text("카드 추가") }, + title = { Text(title) }, navigationIcon = { IconButton(onClick = { onBackClick() }) { Icon( diff --git a/app/src/main/java/nextstep/payments/ui/new_card/NewCardViewModel.kt b/app/src/main/java/nextstep/payments/ui/new_card/NewCardViewModel.kt index 5abbdaae..80f55079 100644 --- a/app/src/main/java/nextstep/payments/ui/new_card/NewCardViewModel.kt +++ b/app/src/main/java/nextstep/payments/ui/new_card/NewCardViewModel.kt @@ -1,5 +1,6 @@ package nextstep.payments.ui.new_card +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel @@ -10,35 +11,71 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nextstep.payments.data.card.CardEntity import nextstep.payments.data.card.PaymentCardRepository +import nextstep.payments.ui.common.model.CreditCard import nextstep.payments.ui.mapper.toData -class NewCardViewModel( +// Hilt가 아닌 기본 viewModel 함수를 이용해 ViewModel을 생성할 때, savedStateHandle와 디폴트 인자를 함께 사용하면 +// ViewModel factory가 SavedStateHandle만 받는 생성자를 찾지 못해 에러가 발생한다고 합니다. +// 그래서 savedStateHandle만 있는 생성자를 만들기 위해 JvmOverloads를 이용했습니다. +class NewCardViewModel @JvmOverloads constructor( + savedStateHandle: SavedStateHandle, private val cardRepository: PaymentCardRepository = PaymentCardRepository, ) : ViewModel() { - private val _cardState = MutableStateFlow(NewCardState()) - val cardState = _cardState.asStateFlow() - private val eventChannel = Channel { } val events = eventChannel.receiveAsFlow() + private val _cardState: MutableStateFlow + + val originalState: NewCardState + + init { + // 전달된 카드가 있다면 꺼내서 state에 저장하기 + val cardInfo = savedStateHandle.remove(CARD_INFO_KEY) + + originalState = if (cardInfo != null) { + // repository에서 비밀번호 알아내기 + val pw = cardRepository.getPassword(cardInfo.id) + + NewCardState( + cardNumber = cardInfo.cardNumber, + expiredDate = cardInfo.expiredDate, + ownerName = cardInfo.ownerName, + password = pw, + showBottomSheet = false, + cardType = cardInfo.company, + id = cardInfo.id, + ) + } else { + NewCardState.EMPTY + } + _cardState = MutableStateFlow(originalState) + } + + val cardState = _cardState.asStateFlow() + fun onAction(action: NewCardAction) { when (action) { is NewCardAction.OnCartNumberChange -> { setCardNumber(action.cardNumber) } + is NewCardAction.OnExpiredDateChange -> { setExpiredDate(action.expiredDate) } + is NewCardAction.OnOwnerNameChange -> { setOwnerName(action.ownerName) } + is NewCardAction.OnPasswordChange -> { setPassword(action.password) } + NewCardAction.OnAddCardClick -> { addCard() } + NewCardAction.OnBackClick -> { // 현재 상황에서는 이러한 로직이 불필요해 보이지만, eventChannel이 꽉차서 send에 실패할 경우 재시도할 수 있도록 trySend 대신 send를 이용했습니다. // 하지만 trySend 대신 send를 이용할 경우 coroutineScope이 필요합니다. @@ -108,20 +145,26 @@ class NewCardViewModel( CardInputValidator.isCardOwnerNameValid(_cardState.value.ownerName) && CardInputValidator.isPasswordValid(_cardState.value.password) ) { - val isSuccess = cardRepository.addCard( - CardEntity( - cardNumber = _cardState.value.cardNumber, - expiredDate = _cardState.value.expiredDate, - ownerName = _cardState.value.ownerName, - password = _cardState.value.password, - company = _cardState.value.cardType.toData(), - ) + val cardEntity = CardEntity( + id = originalState.id, + cardNumber = _cardState.value.cardNumber, + expiredDate = _cardState.value.expiredDate, + ownerName = _cardState.value.ownerName, + password = _cardState.value.password, + company = _cardState.value.cardType.toData(), ) - - val event = if (isSuccess) { - NewCardEvent.CardAddSuccess + val event = if (originalState != NewCardState.EMPTY) { + if (cardRepository.editCard(originalState.id, cardEntity)) { + NewCardEvent.CardEditSuccess + } else { + NewCardEvent.CardEditFail + } } else { - NewCardEvent.CardAddFail + if (cardRepository.addCard(cardEntity)) { + NewCardEvent.CardAddSuccess + } else { + NewCardEvent.CardAddFail + } } viewModelScope.launch { @@ -138,4 +181,8 @@ class NewCardViewModel( ) } } + + companion object { + const val CARD_INFO_KEY = "exist_card_info" + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e556a57..96ab9191 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,4 +12,7 @@ 새로운 카드를 등록해주세요 추가 카드 등록에 실패했습니다. + 카드 추가 + 카드 변경 + 카드 변경에 실패했습니다. diff --git a/build.gradle.kts b/build.gradle.kts index c1e23bcf..0c9efb47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.parcelize) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a56750c..b4bf395a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,3 +36,4 @@ androidx-material-icons-extended = { group = "androidx.compose.material", name = android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }