diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9996218f..fb434ec6 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.androidx.navigation.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/nextstep/payments/MainActivity.kt b/app/src/main/java/nextstep/payments/MainActivity.kt index 39aa3ada..2144c1d3 100644 --- a/app/src/main/java/nextstep/payments/MainActivity.kt +++ b/app/src/main/java/nextstep/payments/MainActivity.kt @@ -6,12 +6,9 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import nextstep.payments.ui.screen.NewCardScreen -import nextstep.payments.ui.screen.NewCardViewModel +import androidx.navigation.compose.rememberNavController +import nextstep.payments.ui.navigation.PaymentsNavigationHost import nextstep.payments.ui.theme.PaymentsTheme class MainActivity : ComponentActivity() { @@ -24,7 +21,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - NewCardScreen() + PaymentsNavigationHost(navHostController = rememberNavController()) } } } diff --git a/app/src/main/java/nextstep/payments/domain/PaymentCardsRepository.kt b/app/src/main/java/nextstep/payments/domain/PaymentCardsRepository.kt new file mode 100644 index 00000000..e3442c14 --- /dev/null +++ b/app/src/main/java/nextstep/payments/domain/PaymentCardsRepository.kt @@ -0,0 +1,13 @@ +package nextstep.payments.domain + +import nextstep.payments.ui.model.PaymentCardModel + +object PaymentCardsRepository { + + private val _cards = mutableListOf() + val cards: List get() = _cards.toList() + + fun addCard(card: PaymentCardModel) { + _cards.add(card) + } +} 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 52690c30..6faf921b 100644 --- a/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt +++ b/app/src/main/java/nextstep/payments/ui/component/PaymentCard.kt @@ -1,20 +1,114 @@ package nextstep.payments.ui.component 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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +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.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp @Composable -fun PaymentCard( +fun AddPaymentCard( modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + DefaultCard( + modifier = modifier, + color =Color(0xFFE5E5E5), + content = { + Box( + modifier = Modifier + .fillMaxSize() + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add", + tint = Color.Black, + ) + } + } + ) +} + +@Composable +fun DefaultPaymentCard( + modifier: Modifier = Modifier, +) { + DefaultCard( + modifier = modifier, + content = { + CardIcChip(Modifier.padding(start = 14.dp, bottom = 10.dp)) + } + ) +} + +@Composable +fun RegisteredPaymentCard( + modifier: Modifier = Modifier, +) { + DefaultCard( + modifier = modifier, + content = { + + Column( + Modifier + .fillMaxSize() + .padding(start = 14.dp, end = 14.dp, top = 44.dp, bottom = 16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + CardIcChip() + Text( + modifier = modifier.fillMaxWidth(), + text = "1234-5678-1234-5678", + fontSize = 12.sp, + color = Color.White, + maxLines = 1 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "JINHYUK JANG", + color = Color.White, + fontSize = 12.sp + ) + Text( + modifier = modifier, + text = "00/00", + fontSize = 12.sp, + color = Color.White, + ) + } + } + } + ) +} + +@Composable +fun DefaultCard( + modifier: Modifier = Modifier, + color:Color = Color(0xFF333333), + content: @Composable () -> Unit = {} ) { Box( contentAlignment = Alignment.CenterStart, @@ -22,18 +116,42 @@ fun PaymentCard( .shadow(8.dp) .size(width = 208.dp, height = 124.dp) .background( - color = Color(0xFF333333), + color = color, shape = RoundedCornerShape(5.dp), ) ) { - Box( - modifier = Modifier - .padding(start = 14.dp, bottom = 10.dp) - .size(width = 40.dp, height = 26.dp) - .background( - color = Color(0xFFCBBA64), - shape = RoundedCornerShape(4.dp), - ) - ) + content() } } + +@Composable +fun CardIcChip( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(width = 40.dp, height = 26.dp) + .background( + color = Color(0xFFCBBA64), + shape = RoundedCornerShape(4.dp), + ) + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddPaymentCardPreview() { + AddPaymentCard() +} + +@Preview(showBackground = true) +@Composable +private fun DefaultPaymentCardPreview() { + DefaultPaymentCard() +} + +@Preview(showBackground = true) +@Composable +private fun RegisteredPaymentCardPreview() { + RegisteredPaymentCard() +} diff --git a/app/src/main/java/nextstep/payments/ui/model/PaymentCardModel.kt b/app/src/main/java/nextstep/payments/ui/model/PaymentCardModel.kt new file mode 100644 index 00000000..442e5c76 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/model/PaymentCardModel.kt @@ -0,0 +1,8 @@ +package nextstep.payments.ui.model + +data class PaymentCardModel( + val cardNumber: String, + val ownerName: String, + val expiredDate: String, + val password: String +) diff --git a/app/src/main/java/nextstep/payments/ui/navigation/NavigationModel.kt b/app/src/main/java/nextstep/payments/ui/navigation/NavigationModel.kt new file mode 100644 index 00000000..4340d106 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/navigation/NavigationModel.kt @@ -0,0 +1,6 @@ +package nextstep.payments.ui.navigation + +sealed class NavigationModel(val route: String) { + data object AddPaymentCard : NavigationModel("AddCard") + data object PaymentCards : NavigationModel("PaymentCards") +} diff --git a/app/src/main/java/nextstep/payments/ui/navigation/PaymentsNavigationHost.kt b/app/src/main/java/nextstep/payments/ui/navigation/PaymentsNavigationHost.kt new file mode 100644 index 00000000..91091cb4 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/navigation/PaymentsNavigationHost.kt @@ -0,0 +1,30 @@ +package nextstep.payments.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import nextstep.payments.ui.screen.NewCardScreenRoute +import nextstep.payments.ui.screen.PaymentCardsScreenRoute + +@Composable +fun PaymentsNavigationHost( + navHostController: NavHostController +) { + NavHost( + navController = navHostController, + startDestination = NavigationModel.PaymentCards.route + ) { + composable(NavigationModel.PaymentCards.route) { navBackResult -> + PaymentCardsScreenRoute( + onAddCardClick = { navHostController.navigate(NavigationModel.AddPaymentCard.route) }, + ) + } + composable(NavigationModel.AddPaymentCard.route) { + NewCardScreenRoute( + onBackClick = { navHostController.popBackStack() }, + onAddComplete = { navHostController.popBackStack() } + ) + } + } +} diff --git a/app/src/main/java/nextstep/payments/ui/screen/NewCardScreen.kt b/app/src/main/java/nextstep/payments/ui/screen/NewCardScreen.kt index 9ad4ac18..c396f124 100644 --- a/app/src/main/java/nextstep/payments/ui/screen/NewCardScreen.kt +++ b/app/src/main/java/nextstep/payments/ui/screen/NewCardScreen.kt @@ -18,15 +18,32 @@ 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 nextstep.payments.ui.component.DefaultPaymentCard import nextstep.payments.ui.component.NewCardTopBar -import nextstep.payments.ui.component.PaymentCard import nextstep.payments.ui.theme.PaymentsTheme +@Composable +fun NewCardScreenRoute( + onBackClick: () -> Unit, + onAddComplete: () -> Unit, + viewModel: NewCardViewModel = viewModel(), +) { + NewCardScreen( + onBackClick = onBackClick, + onSaveClick = { + viewModel.addCard() + onAddComplete() + }, + viewModel = viewModel, + ) +} //Stateful한 NewCardScreen @Composable internal fun NewCardScreen( modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onSaveClick: () -> Unit, viewModel: NewCardViewModel = viewModel(), ) { val cardNumber by viewModel.cardNumber.collectAsStateWithLifecycle() @@ -44,6 +61,8 @@ internal fun NewCardScreen( setOwnerName = viewModel::setOwnerName, setPassword = viewModel::setPassword, modifier = modifier, + onBackClick = onBackClick, + onSaveClick = onSaveClick, ) } @@ -59,9 +78,11 @@ private fun NewCardScreen( setOwnerName: (String) -> Unit, setPassword: (String) -> Unit, modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onSaveClick: () -> Unit, ) { Scaffold( - topBar = { NewCardTopBar(onBackClick = { TODO() }, onSaveClick = { TODO() }) }, + topBar = { NewCardTopBar(onBackClick = { onBackClick() }, onSaveClick = { onSaveClick() }) }, modifier = modifier ) { innerPadding -> Column( @@ -73,7 +94,7 @@ private fun NewCardScreen( ) { Spacer(modifier = Modifier.height(14.dp)) - PaymentCard() + DefaultPaymentCard() Spacer(modifier = Modifier.height(10.dp)) @@ -125,7 +146,9 @@ fun NewCardScreenPreview() { setExpiredDate("00 / 00") setOwnerName("홍길동") setPassword("password") - } + }, + onBackClick = { }, + onSaveClick = { } ) } } @@ -144,6 +167,8 @@ fun StatelessNewCardScreenPreview() { setExpiredDate = { }, setOwnerName = { }, setPassword = { }, + onBackClick = { }, + onSaveClick = { } ) } } diff --git a/app/src/main/java/nextstep/payments/ui/screen/NewCardViewModel.kt b/app/src/main/java/nextstep/payments/ui/screen/NewCardViewModel.kt index d8bb4f7c..5e9a4969 100644 --- a/app/src/main/java/nextstep/payments/ui/screen/NewCardViewModel.kt +++ b/app/src/main/java/nextstep/payments/ui/screen/NewCardViewModel.kt @@ -4,8 +4,15 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import nextstep.payments.domain.PaymentCardsRepository +import nextstep.payments.ui.model.PaymentCardModel -class NewCardViewModel : ViewModel() { +class NewCardViewModel( + private val repository: PaymentCardsRepository = PaymentCardsRepository +) : ViewModel() { + + private val _cardAdded = MutableStateFlow(false) + val cardAdded: StateFlow = _cardAdded.asStateFlow() private val _cardNumber = MutableStateFlow("") val cardNumber: StateFlow = _cardNumber.asStateFlow() @@ -34,4 +41,16 @@ class NewCardViewModel : ViewModel() { fun setPassword(password: String) { _password.value = password } + + fun addCard() { + repository.addCard( + PaymentCardModel( + cardNumber = cardNumber.value, + expiredDate = expiredDate.value, + ownerName = ownerName.value, + password = password.value + ) + ) + _cardAdded.value = true + } } diff --git a/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsScreen.kt b/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsScreen.kt new file mode 100644 index 00000000..facb2332 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsScreen.kt @@ -0,0 +1,149 @@ +package nextstep.payments.ui.screen + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +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 nextstep.payments.ui.component.AddPaymentCard +import nextstep.payments.ui.component.RegisteredPaymentCard +import nextstep.payments.ui.state.PaymentCardUiState + +@Composable +fun PaymentCardsScreenRoute( + onAddCardClick: () -> Unit, + viewModel: PaymentCardsViewModel = viewModel(), +) { + LaunchedEffect(key1 = Unit, block = { + viewModel.loadCardPayments() + }) + val uiState by viewModel.cardsScreenState.collectAsState() + PaymentCardsScreen( + uiState = uiState, + onAddCardClick = onAddCardClick + ) +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PaymentCardsScreen( + uiState: PaymentCardUiState, + onAddCardClick: () -> Unit +) { + val visible = uiState is PaymentCardUiState.Many + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "Payments", + fontSize = 22.sp + ) + }, + actions = { + if (visible) { + TextButton(onClick = { onAddCardClick() }) { + Text( + text = "추가", + color = if(isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + } + } + }, + ) + } + ) { paddingModifier -> + PaymentCardList( + modifier = Modifier.padding(paddingModifier), + uiState = uiState, + onAddCardClick = onAddCardClick, + ) + } +} + + +@Composable +fun PaymentCardList( + modifier: Modifier = Modifier, + uiState: PaymentCardUiState, + onAddCardClick: () -> Unit, +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + PaymentCardUiState.Empty -> { + item { + Text( + modifier = modifier.fillMaxWidth(), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + text = "새로운 카드를 등록해주세요", + textAlign = TextAlign.Center + ) + } + item { + AddPaymentCard( + onClick = onAddCardClick + ) + } + } + + is PaymentCardUiState.One -> { + item { + RegisteredPaymentCard() + } + item { + AddPaymentCard( + onClick = onAddCardClick + ) + } + } + + is PaymentCardUiState.Many -> { + items(uiState.cards.size) { + RegisteredPaymentCard() + } + item { + AddPaymentCard( + onClick = onAddCardClick + ) + } + } + } + } +} + +@Preview +@Composable +fun PaymentCardsScreenPreview() { + PaymentCardsScreen( + uiState = PaymentCardUiState.Empty, + onAddCardClick = {} + ) +} diff --git a/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsViewModel.kt b/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsViewModel.kt new file mode 100644 index 00000000..9e223d7b --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/screen/PaymentCardsViewModel.kt @@ -0,0 +1,23 @@ +package nextstep.payments.ui.screen + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import nextstep.payments.domain.PaymentCardsRepository +import nextstep.payments.ui.state.PaymentCardUiState + +class PaymentCardsViewModel( + private val paymentCardsRepository: PaymentCardsRepository = PaymentCardsRepository +) : ViewModel() { + private val _cardsScreenState = MutableStateFlow(PaymentCardUiState.Empty) + val cardsScreenState: StateFlow = _cardsScreenState.asStateFlow() + + fun loadCardPayments() { + when { + paymentCardsRepository.cards.isEmpty() -> _cardsScreenState.value = PaymentCardUiState.Empty + paymentCardsRepository.cards.count() == 1 -> _cardsScreenState.value = PaymentCardUiState.One(paymentCardsRepository.cards.first()) + else -> _cardsScreenState.value = PaymentCardUiState.Many(paymentCardsRepository.cards) + } + } +} diff --git a/app/src/main/java/nextstep/payments/ui/state/PaymentCardUiState.kt b/app/src/main/java/nextstep/payments/ui/state/PaymentCardUiState.kt new file mode 100644 index 00000000..1e1c79f8 --- /dev/null +++ b/app/src/main/java/nextstep/payments/ui/state/PaymentCardUiState.kt @@ -0,0 +1,9 @@ +package nextstep.payments.ui.state + +import nextstep.payments.ui.model.PaymentCardModel + +sealed interface PaymentCardUiState { + data object Empty : PaymentCardUiState + data class One(val card: PaymentCardModel) : PaymentCardUiState + data class Many(val cards: List): PaymentCardUiState +} diff --git a/app/src/main/java/nextstep/payments/ui/theme/Theme.kt b/app/src/main/java/nextstep/payments/ui/theme/Theme.kt index cdc30f51..f80dc11f 100644 --- a/app/src/main/java/nextstep/payments/ui/theme/Theme.kt +++ b/app/src/main/java/nextstep/payments/ui/theme/Theme.kt @@ -2,6 +2,7 @@ package nextstep.payments.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -39,15 +40,7 @@ fun PaymentsTheme( dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = getColorScheme(darkTheme, dynamicColor) MaterialTheme( colorScheme = colorScheme, @@ -55,3 +48,17 @@ fun PaymentsTheme( content = content ) } + +@Composable +fun getColorScheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, +): ColorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee0e55c2..425522a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,9 +8,11 @@ espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.8.4" activityCompose = "1.9.1" composeBom = "2024.06.00" +navigationCompose = "2.7.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }