Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[오둥이] 장바구니 미션 제출합니다. #4

Open
wants to merge 14 commits into
base: murjune
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
# android-shopping-cart
# 1 단계 목록

- [x] : 상품 목록을 타이틀과 장바구니 아이콘을 보여준다
- [x]: 상품 목록에는 상품 사진, 상품명, 가격을 보여준다.
- [x]: 상품의 이름이 레이아웃을 넘어갈 경우 뒤에 "..."을 붙여준다.
- [x]: SpanCount 2인 GridView 로 상품 목록을 보여준다.
- [x]: 1,000원 단위로 콤마(,)를 찍어준다.

# 2 단계 목록

- [x]: 상품 상세 화면을 구현한다.
- [x]: 상품 목록에서 상품을 누르면 상품 상세 화면으로 이동한다.
- [x]: 뒤로 가기 버튼이나 아이콘을 누르면 직전 화면으로 돌아간다.
- [x]: 장바구니 화면의 빈 껍데기를 연결한다.
- [x]: 상품 목록에서 장바구니 아이콘을 누르면 장바구니 화면으로 이동한다.
- [x]: 상품 상세에서 장바구니 담기 버튼을 누르면 장바구니 화면으로 이동한다.
- [x]: 뒤로 가기 버튼이나 아이콘을 누르면 직전 화면으로 돌아간다.
- [x]: 장바구니에 실제로 상품이 담기는 기능은 이 단계에서 고려하지 않는다.

# 3 단계 목록

- [x]: 상품을 장바구니에 담는 기능을 구현한다.
- [x]: 장바구니 화면을 구현한다.
- [x]: 담긴 상품의 수량을 조절할 수 있어야 한다.
- [x]: 수량을 1보다 작게 하면 장바구니에서 상품이 제거된다
- [x]: 담긴 상품 가격의 총합이 주문하기 버튼에 표시된다.
- [x]: 주문 완료 시 장바구니가 비워진다.

# 4 단계 목록

- [x]: 상품 목록에서 장바구니에 담을/담긴 상품의 수량을 조절할 수 있다.
- [x]: + 아이콘을 누르면 장바구니에 상품이 추가됨과 동시에 수량 조절 버튼이 노출된다.
- [x]: 상품 목록의 상품 수가 변화하면 장바구니에도 반영되어야 한다. (B마트 UX 참고)
- [x]: 장바구니의 상품 수가 변화하면 상품 목록에도 반영되어야 한다. (B마트 UX 참고)
- [x]: 반복되는 뷰(상품 수량 조절)를 재사용할 수 있는 방법을 고민해 본다.
- [x]: 3단계에서 작성된 장바구니 화면 테스트가 실패하면 안 된다.
11 changes: 8 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.navigation.safe.args)
}

android {
namespace = "nextstep.signup"
namespace = "nextstep.shoppingcart"
compileSdk = 34

defaultConfig {
applicationId = "nextstep.signup"
applicationId = "nextstep.shoppingcart"
minSdk = 26
targetSdk = 34
versionCode = 1
Expand Down Expand Up @@ -50,7 +52,8 @@ android {
}

dependencies {

implementation(libs.kotlin.serialization.json)
implementation(libs.navigation.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
Expand All @@ -59,11 +62,13 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.coil.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.navigation.compose.test)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package nextstep.shoppingcart.data

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import nextstep.shoppingcart.domain.model.CartProduct
import nextstep.shoppingcart.domain.model.Product
import nextstep.shoppingcart.domain.repository.CartRepository

class FakeCartRepository(
cartProducts: List<CartProduct> = emptyList(),
private val products: List<Product> = emptyList(),
) : CartRepository {
private val cartProducts: MutableStateFlow<List<CartProduct>> = MutableStateFlow(cartProducts)

override fun addProduct(productId: Long, count: Int) {
val product = products.find { it.id == productId }
requireNotNull(product) {
"$productId 에 해당하는 상품이 없습니다."
}
val cartProduct: CartProduct? = cartProducts.value.find { it.product.id == productId }
if (cartProduct != null) {
val newCartProduct = cartProduct.copy(count = cartProduct.count + count)
val newCartProducts = cartProducts.value.map {
if (it.product.id == productId) newCartProduct else it
}
cartProducts.value = newCartProducts
return
}
val newCartProduct = CartProduct(
product = product,
count = count
)
cartProducts.value += newCartProduct
}

override fun removeProduct(productId: Long, count: Int) {
val cartProduct: CartProduct? = cartProducts.value.find { it.product.id == productId }
requireNotNull(cartProduct) {
"$productId 에 해당하는 상품이 장바구니에 없습니다."
}
if (cartProduct.count <= count) {
cartProducts.value = cartProducts.value.filter { it.product.id != productId }
return
}
val newCartProduct = cartProduct.copy(count = cartProduct.count - count)
val newCartProducts = cartProducts.value.map {
if (it.product.id == productId) newCartProduct else it
}
cartProducts.value = newCartProducts
}

override fun clearProduct(productId: Long) {
requireNotNull(cartProducts.value.any { it.product.id == productId }) {
"$productId 에 해당하는 상품이 장바구니에 없습니다."
}
cartProducts.value = cartProducts.value.filter { it.product.id != productId }
}

override fun cartProducts(): Flow<List<CartProduct>> = cartProducts

override fun clear() {
cartProducts.value = emptyList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package nextstep.shoppingcart.data

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import nextstep.shoppingcart.domain.model.Product
import nextstep.shoppingcart.domain.repository.ProductRepository

class FakeProductRepository(
private val products: List<Product> = emptyList()
) : ProductRepository {

constructor(vararg products: Product) : this(products.toList())

override fun products(): Flow<List<Product>> = flow {
emit(products)
}

override fun productBy(id: Long): Product? = products.find { it.id == id }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package nextstep.shoppingcart.fixtures

import nextstep.shoppingcart.domain.model.CartProduct
import nextstep.shoppingcart.domain.model.Product

fun cartProduct(
id: Long = 1L,
name: String = "상품",
count: Int = 1,
price: Int = 1000,
): CartProduct = CartProduct(
product = Product(id, "testURL", name, price),
count = count
)
12 changes: 12 additions & 0 deletions app/src/androidTest/java/nextstep/shoppingcart/fixtures/Product.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.shoppingcart.fixtures

import nextstep.shoppingcart.domain.model.Product


fun product(
id: Long = 1L,
name: String = "상품",
price: Int = 1000,
): Product = Product(
id, "testURL", name, price
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package nextstep.shoppingcart.presentation

import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.navigation.toRoute
import nextstep.shoppingcart.presentation.cart.CartScreen
import nextstep.shoppingcart.data.FakeProductRepository
import nextstep.shoppingcart.domain.repository.ProductRepository
import nextstep.shoppingcart.presentation.product.ProductRoute
import nextstep.shoppingcart.domain.model.Product
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class NavigationTest {

@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController

@Before
fun setupAppNavHost() {
composeTestRule.setContent {
ProductRepository.set(
FakeProductRepository(
products = listOf(
Product(id = 1L, imageUrl = "testUrl", name = "오둥이", price = 1_000)
)
)
)
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
ShoppingNavHost(navHostController = navController)
}
}

@Test
fun test_navigation_to_cart() {
// when
composeTestRule
.onNodeWithContentDescription("장바구니로 이동")
.performClick()
// then
val currentDestination = navController.currentDestination?.hasRoute<CartScreen>()
assert(currentDestination == true)
}

@Test
fun test_navigation_to_product_detail() {
// when
composeTestRule
.onNodeWithText("오둥이")
.performClick()
// then
val currentDestination = navController.currentDestination?.hasRoute<ProductRoute.Detail>()
val product = navController.currentBackStackEntry?.toRoute<ProductRoute.Detail>()
assert(currentDestination == true)
assert(product?.productId == 1L)
}
}
Loading