diff --git a/README.md b/README.md
index a8bf0de8b..6f9284e08 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,56 @@
-# android-shopping-order
\ No newline at end of file
+# android-shopping-order
+
+### 1,2단계 기능 요구 사항
+- 스켈레톤 UI 노출
+ - [x] 상품 목록
+ - [x] 장바구니
+- 서버 연동
+ - [x] 장바구니 아이템
+ - [ ] 주문
+ - [x] 상품
+- [x] 사용자 인증 정보 저장
+- [x] 장바구니 화면에서 특정 상품만 골라 주문하기 버튼을 누를 수 있다.
+- [x] 별도의 화면에서 상품 추천 알고리즘으로 사용자에게 적절한 상품을 추천해준다. (쿠팡 UX 참고)
+- [x] 상품 추천 알고리즘은 최근 본 상품 카테고리를 기반으로 최대 10개 노출한다.
+- [x] 예를 들어 가장 최근에 본 상품이 fashion 카테고리라면, fashion 상품 10개 노출
+- [x] 해당 카테고리 상품이 10개 미만이라면 해당하는 개수만큼만 노출
+- [x] 장바구니에 이미 추가된 상품이라면 미노출
+- [x] 추천된 상품을 해당 화면에서 바로 추가하여 같이 주문할 수 있다.
+
+### 코니 요구사항
+- [x] 리드미 작성, PR 작성 잘하기
+
+상품 목록 화면 구현
+- [x] 최근 본 상품이 0개인 경우, 최근 본 상품 목록 삭제
+
+Cart 화면 구현
+- [x] 추천된 상품을 해당 화면에서 바로 추가하여 같이 주문할 수 있다. 구현
+- [x] 총 가격은 변하는데 왜 체크 박스는 변하지 않는 기능 수정
+
+- [x] ShoppingApplication에 thread { DevMockServer.start() } 삭제
+- [x] ShoppingApplication에서 정말 SharedPreferences에 값을 저장하고 있는 지 확인 + SharedPreference에서 어떻게 동작하는 지 확인
+- [x] CatalogViewModel에서 catalogProduct, quantity 사용하지 않는 값 삭제
+- [x] CatalogViewModel에서 interface에서 remoteCatalogProductRepositoryImpl 실제 구현체 주입 -> 수정
+- [x] 최근 상품 목록이 0개인 경우, 최근 본 상품 목록 뷰가 보이지 않게 하기
+- [x] baseUrl 노출하지 않도록 수정
+- [x] 사용자 정보 local.properties에 저장 -> BuildConfig로 갖고 오게 끔 구현
+ - [ ] 레벨업 부분 ) 인증에 필요한 key은 secerets에 저장
+- CartActivity에서 생각해야 할 부분
+ - [x] CartActivity에서 hasHandledTotalCount가 정말 필요한 로직일지 생각
+ - [x] View에서 totalCount가 몇인지에 따라서 어떤한 fragment를 commit할지 아닌 ViewModel에서 어떠한 상태를 두고 그 상태에 따라서 화면을 이동하는 것
+ - [x] View까지 와서 단순 ViewModel의 함수를 호출하는 observe를 해야하는 지 생각 -> ViewModel의 일은 스스로 하게 변
+- CartRecommendationFragment
+ - [x] 로그 삭제
+ - [x] 이런 식으로 Unit으로 처리를 하게 될 함수가 필수 overide 라면 ProductActionListener 에서 저 함수가 필수가 아니었던 게 아닌지 생각
+ - [x] collect는 viewLifecycleOwner 따라가게끔 해주셨는데, 데이터 바인딩에 사용될 lifecycleOwner 는 this 를 넘기고 있음
+- CartSelectionFragment
+ - [x] 아직 View가 만들어지기 전에 view에 필요한 데이터를 미리 observe를 하고 adpater를 세팅할 필요한지
+ - [x] onCreateView에서는 어떠한 동작을 해야 하고 어떠한 단계인지 생각
+ - [x] ViewModelStoreOwner를 Activity로 잡은 점
+ - [x] Application이 왜 필요로 하는가?
+ - [x] Enabled 된 것이랑 updateCartItems 를 호출하는 상관관계 -> 전반적으로 view까지 observe가 되어야 했던 값들일까 하는 의문
+ - [x] 데이터 바인딩 적용
+ - [x] updateCartItems()가 view에서 처리해야하는 지 생각
+- CartViewModel
+ - [x] totalCount가 -1 라는 것은 논리적으로 읽을 수 없음 -> 상수화
+----- https://github.com/woowacourse/android-shopping-order/pull/111#discussion_r2113932605 여기 부터 적용 다시
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0e9da592f..5bf773121 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,7 +1,11 @@
+import java.util.Properties
+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.android.junit5)
alias(libs.plugins.kotlin.android)
+ id("kotlin-kapt")
+ id("kotlin-parcelize")
}
android {
@@ -16,7 +20,18 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder"
+ testInstrumentationRunnerArguments["runnerBuilder"] =
+ "de.mannodermaus.junit5.AndroidJUnit5Builder"
+
+ val localProperties = Properties()
+ val localPropertiesFile = rootProject.file("local.properties")
+ if (localPropertiesFile.exists()) {
+ localProperties.load(localPropertiesFile.inputStream())
+ }
+
+ buildConfigField("String", "USER_ID", "\"${localProperties.getProperty("USER_ID")}\"")
+ buildConfigField("String", "USER_PASSWORD", "\"${localProperties.getProperty("USER_PASSWORD")}\"")
+ buildConfigField("String", "BASE_URL", "\"${localProperties.getProperty("BASE_URL")}\"")
}
buildTypes {
@@ -41,17 +56,36 @@ android {
excludes += "win32-x86*/**"
}
}
+ buildFeatures {
+ dataBinding = true
+ buildConfig = true
+ }
}
dependencies {
+ implementation(libs.glide)
+ implementation(libs.androidx.room.runtime)
+ kapt(libs.androidx.room.compiler)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core.ktx)
implementation(libs.google.material)
+ implementation(libs.androidx.activity)
+ implementation(libs.okhttp)
+ implementation(libs.gson)
+ implementation(libs.mockwebserver)
+ implementation(libs.shimmer)
+ implementation(libs.retrofit)
+ implementation(libs.converter.gson)
+ implementation(libs.logging.interceptor)
+ implementation(libs.androidx.fragment.ktx)
+
+ testImplementation(libs.androidx.core.testing)
testImplementation(libs.assertj.core)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotest.runner.junit5)
+
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fdde9c82d..48af2e804 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,12 @@
+
+
+
+
@@ -23,4 +34,4 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/java/woowacourse/shopping/MainActivity.kt b/app/src/main/java/woowacourse/shopping/MainActivity.kt
deleted file mode 100644
index ef3691611..000000000
--- a/app/src/main/java/woowacourse/shopping/MainActivity.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package woowacourse.shopping
-
-import android.os.Bundle
-import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-
-class MainActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContentView(R.layout.activity_main)
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
- }
- }
-}
diff --git a/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt b/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt
new file mode 100644
index 000000000..1d6ac29c9
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt
@@ -0,0 +1,14 @@
+package woowacourse.shopping
+
+import android.app.Application
+import woowacourse.shopping.data.database.ShoppingDatabase
+
+class ShoppingApplication : Application() {
+ lateinit var database: ShoppingDatabase
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ database = ShoppingDatabase.getInstance(applicationContext)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt b/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt
new file mode 100644
index 000000000..4ce88fcd8
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt
@@ -0,0 +1,79 @@
+package woowacourse.shopping.cart
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.commit
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.R
+import woowacourse.shopping.cart.recoomendation.RecommendationFragment
+import woowacourse.shopping.cart.selection.SelectionFragment
+import woowacourse.shopping.databinding.ActivityCartBinding
+
+class CartActivity : AppCompatActivity() {
+ private val binding: ActivityCartBinding by lazy {
+ DataBindingUtil.setContentView(this, R.layout.activity_cart)
+ }
+ private val viewModel: CartViewModel by lazy {
+ ViewModelProvider(
+ this,
+ CartViewModelFactory(),
+ )[CartViewModel::class.java]
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ initView()
+ initActionBar()
+ initDataBinding()
+ observeData()
+ }
+
+ private fun initView() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
+ insets
+ }
+ }
+
+ private fun initActionBar() {
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.title = getString(R.string.text_cart_action_bar)
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ finish()
+ return super.onSupportNavigateUp()
+ }
+
+ private fun initDataBinding() {
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ }
+
+ private fun observeData() {
+ viewModel.totalPurchaseCount.observe(this) { totalPurchaseCount ->
+ val fragment =
+ when (totalPurchaseCount) {
+ 0 -> RecommendationFragment()
+ else -> SelectionFragment()
+ }
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.fragment_container_cart_selection, fragment)
+ }
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context): Intent = Intent(context, CartActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt b/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt
new file mode 100644
index 000000000..42c315771
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt
@@ -0,0 +1,64 @@
+package woowacourse.shopping.cart
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.cart.CartItem.PaginationButtonItem
+import woowacourse.shopping.cart.CartItem.ProductItem
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.product.catalog.QuantityControlListener
+
+class CartAdapter(
+ cartItems: List,
+ private val onDeleteProductClick: DeleteProductClickListener,
+ private val onPaginationButtonClick: PaginationButtonClickListener,
+ private val quantityControlListener: QuantityControlListener,
+ private val onCheckClick: CheckClickListener,
+) : RecyclerView.Adapter() {
+ private val cartItems: MutableList = cartItems.toMutableList()
+
+ fun setCartItems(cartProducts: List) {
+ notifyItemRangeRemoved(0, cartItems.size)
+ cartItems.clear()
+ cartItems.addAll(cartProducts)
+ notifyItemRangeInserted(0, cartItems.size)
+ }
+
+ fun setCartItem(product: ProductUiModel) {
+ val index = cartItems.filterIsInstance().indexOfFirst { it.productItem.id == product.id }
+ cartItems[index] = ProductItem(product)
+ notifyItemChanged(index)
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): RecyclerView.ViewHolder =
+ if (viewType == CART_PRODUCT) {
+ CartViewHolder.from(parent, onDeleteProductClick, quantityControlListener, onCheckClick)
+ } else {
+ PaginationButtonViewHolder.from(parent, onPaginationButtonClick)
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ ) {
+ when (holder) {
+ is CartViewHolder -> holder.bind((cartItems[position] as ProductItem).productItem)
+ is PaginationButtonViewHolder -> holder.bind(cartItems[position] as PaginationButtonItem)
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int =
+ when (cartItems[position]) {
+ is PaginationButtonItem -> PAGINATION_BUTTON
+ is ProductItem -> CART_PRODUCT
+ }
+
+ override fun getItemCount(): Int = cartItems.size
+
+ companion object {
+ private const val CART_PRODUCT = 1
+ private const val PAGINATION_BUTTON = 2
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartItem.kt b/app/src/main/java/woowacourse/shopping/cart/CartItem.kt
new file mode 100644
index 000000000..b497d39b6
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartItem.kt
@@ -0,0 +1,15 @@
+package woowacourse.shopping.cart
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+sealed class CartItem {
+ data class ProductItem(
+ val productItem: ProductUiModel,
+ ) : CartItem()
+
+ data class PaginationButtonItem(
+ val page: Int,
+ val isNextButtonEnabled: Boolean,
+ val isPrevButtonEnabled: Boolean,
+ ) : CartItem()
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt
new file mode 100644
index 000000000..4ef238aff
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt
@@ -0,0 +1,34 @@
+package woowacourse.shopping.cart
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.databinding.CartItemBinding
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.product.catalog.QuantityControlListener
+
+class CartViewHolder(
+ private val binding: CartItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(cartProduct: ProductUiModel) {
+ binding.cartProduct = cartProduct
+ binding.checkboxSelection.isChecked = cartProduct.isChecked
+ }
+
+ companion object {
+ fun from(
+ parent: ViewGroup,
+ onDeleteProductClick: DeleteProductClickListener,
+ quantityControlListener: QuantityControlListener,
+ onCheckClick: CheckClickListener,
+ ): CartViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = CartItemBinding.inflate(inflater, parent, false)
+ binding.clickListener = onDeleteProductClick
+ binding.checkClickListener = onCheckClick
+ binding.layoutQuantityControlBar.quantityControlListener = quantityControlListener
+
+ return CartViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt
new file mode 100644
index 000000000..4d2cb6cab
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt
@@ -0,0 +1,226 @@
+package woowacourse.shopping.cart
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import woowacourse.shopping.cart.CartItem.ProductItem
+import woowacourse.shopping.data.repository.CartRepository
+import woowacourse.shopping.product.catalog.ProductUiModel
+import kotlin.collections.map
+
+class CartViewModel(
+ private val cartRepository: CartRepository,
+) : ViewModel() {
+ private val _cartProducts = MutableLiveData>(emptyList())
+ val cartProducts: LiveData> = _cartProducts
+
+ private val _isNextButtonEnabled = MutableLiveData(false)
+ val isNextButtonEnabled: LiveData = _isNextButtonEnabled
+
+ private val _isPrevButtonEnabled = MutableLiveData(false)
+ val isPrevButtonEnabled: LiveData = _isPrevButtonEnabled
+
+ private val _page = MutableLiveData(INITIAL_PAGE)
+ val page: LiveData = _page
+
+ private val _updatedItem = MutableLiveData()
+ val updatedItem: LiveData = _updatedItem
+
+ private val _loadingState: MutableLiveData =
+ MutableLiveData(true)
+ val loadingState: LiveData get() = _loadingState
+
+ private val _totalPurchaseCount = MutableLiveData(INITIAL_CART_ITEM_COUNT)
+ val totalPurchaseCount: LiveData get() = _totalPurchaseCount
+
+ private val _totalPurchaseAmount = MutableLiveData(0)
+ val totalPurchaseAmount: LiveData get() = _totalPurchaseAmount
+
+ private val _allChecked = MutableLiveData(true)
+ val allChecked: LiveData get() = _allChecked
+
+ init {
+ loadCartProducts()
+ }
+
+ fun onAllSelectedProducts() {
+ val isChecked = _allChecked.value ?: true
+ _cartProducts.value =
+ _cartProducts.value?.map {
+ it.copy(isChecked = !isChecked)
+ }
+ _allChecked.value = !isChecked
+ fetchTotalPurchaseAmount()
+ }
+
+ fun fetchTotalPurchaseAmount() {
+ val amount =
+ _cartProducts.value?.filter { it.isChecked == true }?.sumOf { it.price * it.quantity }
+ ?: 0
+ _totalPurchaseAmount.postValue(amount)
+ }
+
+ fun fetchTotalPurchaseCount() {
+ val counts = _cartProducts.value?.count { it.isChecked == true } ?: 0
+ _totalPurchaseCount.postValue(counts)
+ }
+
+ fun deleteCartProduct(cartProduct: ProductItem) {
+ cartRepository.deleteCartProduct(cartProduct.productItem) { result ->
+ if (result) {
+ val newCartProducts =
+ _cartProducts.value?.filterNot { it == cartProduct.productItem } ?: emptyList()
+ _cartProducts.postValue(newCartProducts)
+ val currentPage = page.value ?: INITIAL_PAGE
+ val startIndex = currentPage * PAGE_SIZE
+ if (startIndex >= newCartProducts.size && currentPage > 0) {
+ decreasePage()
+ }
+ loadCartProducts()
+ }
+ }
+ }
+
+ fun onPaginationButtonClick(buttonDirection: Int) {
+ cartRepository.getTotalProductsCount { totalSize ->
+ val currentPage = page.value ?: INITIAL_PAGE
+ val lastPage = (totalSize - 1) / PAGE_SIZE
+
+ when (buttonDirection) {
+ PREV_BUTTON -> {
+ if (currentPage > 0) {
+ decreasePage()
+ loadCartProducts()
+ }
+ }
+
+ NEXT_BUTTON -> {
+ if (currentPage < lastPage) {
+ increasePage()
+ loadCartProducts()
+ }
+ }
+ }
+ }
+ }
+
+ fun updateQuantity(
+ event: Int,
+ product: ProductUiModel,
+ ) {
+ when (event) {
+ DECREASE_BUTTON -> decreaseQuantity(product)
+ INCREASE_BUTTON -> increaseQuantity(product)
+ }
+ }
+
+ fun increaseQuantity(product: ProductUiModel) {
+ cartRepository.updateCartProduct(
+ product,
+ product.quantity + A_COUNT,
+ ) { success ->
+ if (success) {
+ val newProduct = product.copy(quantity = product.quantity + A_COUNT)
+ updateItem(newProduct)
+ }
+ }
+ }
+
+ fun decreaseQuantity(product: ProductUiModel) {
+ if (product.quantity > INITIAL_PRODUCT_COUNT) {
+ val newProduct = product.copy(quantity = product.quantity - A_COUNT)
+ cartRepository.updateCartProduct(
+ product,
+ product.quantity - A_COUNT,
+ ) { success ->
+ if (success) {
+ updateItem(newProduct)
+ }
+ }
+ }
+ }
+
+ private fun updateItem(newProduct: ProductUiModel) {
+ _updatedItem.postValue(newProduct)
+ _cartProducts.value =
+ _cartProducts.value?.map {
+ if (it.id == newProduct.id) newProduct else it
+ }
+ fetchTotalPurchaseAmount()
+ fetchTotalPurchaseCount()
+ }
+
+ fun changeProductSelection(product: ProductUiModel) {
+ val newProduct = product.copy(isChecked = !product.isChecked ?: true)
+ _cartProducts.value =
+ _cartProducts.value?.map {
+ if (it.id == newProduct.id) newProduct else it
+ }
+ _cartProducts.value?.let { list ->
+ if (list.isEmpty()) return@let
+ val firstChecked = list[0].isChecked ?: false
+ val allSame = list.all { (it.isChecked ?: false) == firstChecked }
+ if (allSame) {
+ _allChecked.value = firstChecked
+ }
+ }
+ fetchTotalPurchaseAmount()
+ fetchTotalPurchaseCount()
+ }
+
+ private fun checkNextButtonEnabled(totalSize: Int) {
+ val currentPage = page.value ?: INITIAL_PAGE
+ val lastPage = (totalSize - 1) / PAGE_SIZE
+ _isNextButtonEnabled.postValue(currentPage < lastPage)
+ }
+
+ private fun checkPrevButtonEnabled() {
+ val currentPage = page.value ?: INITIAL_PAGE
+ _isPrevButtonEnabled.postValue(currentPage >= 1)
+ }
+
+ private fun increasePage() {
+ _page.postValue(page.value?.plus(1))
+ }
+
+ private fun decreasePage() {
+ _page.postValue(page.value?.minus(1))
+ }
+
+ private fun loadCartProducts(pageSize: Int = PAGE_SIZE) {
+ cartRepository.getTotalProductsCount { totalSize ->
+ _totalPurchaseCount.postValue(totalSize)
+ val currentPage = page.value ?: INITIAL_PAGE
+ val startIndex = currentPage * pageSize
+ if (startIndex >= totalSize) {
+ return@getTotalProductsCount
+ }
+ cartRepository
+ .getCartProductsInRange(currentPage, pageSize) { cartProducts ->
+ val pagedProducts: List =
+ cartProducts.map {
+ it.copy(isChecked = true)
+ }
+ _cartProducts.postValue(pagedProducts)
+ checkNextButtonEnabled(totalSize)
+ checkPrevButtonEnabled()
+ _loadingState.postValue(false)
+ val amount =
+ pagedProducts.filter { it.isChecked == true }.sumOf { it.price * it.quantity }
+ _totalPurchaseAmount.postValue(amount)
+ }
+ }
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 5
+ private const val INITIAL_PAGE = 0
+ private const val PREV_BUTTON = 1
+ private const val NEXT_BUTTON = 2
+ private const val DECREASE_BUTTON = 0
+ private const val INCREASE_BUTTON = 1
+ private const val INITIAL_CART_ITEM_COUNT = 0
+ private const val INITIAL_PRODUCT_COUNT: Int = 1
+ private const val A_COUNT: Int = 1
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt
new file mode 100644
index 000000000..6eac18b80
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt
@@ -0,0 +1,24 @@
+package woowacourse.shopping.cart
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.data.repository.CartRepository
+import woowacourse.shopping.data.repository.CartRepositoryImpl
+import woowacourse.shopping.data.source.CartProductRemoteDataSource
+
+@Suppress("UNCHECKED_CAST")
+class CartViewModelFactory : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(CartViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ val cartRepository: CartRepository =
+ CartRepositoryImpl.Companion.initialize(
+ cartProductDataSource = CartProductRemoteDataSource(),
+ )
+ return CartViewModel(
+ cartRepository = cartRepository,
+ ) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt
new file mode 100644
index 000000000..307181a6b
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt
@@ -0,0 +1,7 @@
+package woowacourse.shopping.cart
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+fun interface CheckClickListener {
+ fun onClick(product: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt
new file mode 100644
index 000000000..0e4eee4a7
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt
@@ -0,0 +1,7 @@
+package woowacourse.shopping.cart
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+fun interface DeleteProductClickListener {
+ fun onClick(cartProduct: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt
new file mode 100644
index 000000000..174f337b2
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt
@@ -0,0 +1,5 @@
+package woowacourse.shopping.cart
+
+fun interface PaginationButtonClickListener {
+ fun onClick(dir: Int)
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt
new file mode 100644
index 000000000..c30d671cb
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt
@@ -0,0 +1,28 @@
+package woowacourse.shopping.cart
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.cart.CartItem.PaginationButtonItem
+import woowacourse.shopping.databinding.PaginationButtonItemBinding
+
+class PaginationButtonViewHolder(
+ private val binding: PaginationButtonItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(paginationButtonItem: PaginationButtonItem) {
+ binding.paginationButtonItem = paginationButtonItem
+ binding.executePendingBindings()
+ }
+
+ companion object {
+ fun from(
+ parent: ViewGroup,
+ onPaginationButtonClick: PaginationButtonClickListener,
+ ): PaginationButtonViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = PaginationButtonItemBinding.inflate(inflater, parent, false)
+ binding.clickListener = onPaginationButtonClick
+ return PaginationButtonViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt
new file mode 100644
index 000000000..d4a80645e
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt
@@ -0,0 +1,105 @@
+package woowacourse.shopping.cart.recoomendation
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.R
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.databinding.FragmentCartRecommendationBinding
+import woowacourse.shopping.product.catalog.CatalogItem.ProductItem
+import woowacourse.shopping.product.catalog.ProductActionListener
+import woowacourse.shopping.product.catalog.ProductAdapter
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.product.detail.DetailActivity
+
+class RecommendationFragment : Fragment() {
+ private lateinit var binding: FragmentCartRecommendationBinding
+ private val viewModel: RecommendationFragmentViewModel by lazy {
+ ViewModelProvider(
+ this,
+ RecommendationFragmentViewModelFactory(requireActivity().application as ShoppingApplication),
+ )[RecommendationFragmentViewModel::class.java]
+ }
+
+ private val adapter: ProductAdapter by lazy {
+ ProductAdapter(
+ products = emptyList(),
+ productActionListener =
+ object : ProductActionListener {
+ override fun onProductClick(product: ProductUiModel) {
+ val intent = DetailActivity.newIntent(requireContext(), product)
+ startActivity(intent)
+ }
+
+ override fun onQuantityAddClick(product: ProductUiModel) {
+ viewModel.increaseQuantity(product)
+ }
+ },
+ quantityControlListener = { event, product ->
+ if (event == 1) {
+ viewModel.increaseQuantity(product)
+ } else {
+ viewModel.decreaseQuantity(product)
+ }
+ },
+ )
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? {
+ initViewBinding(inflater, container)
+ return binding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ observeData()
+ }
+
+ private fun initViewBinding(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ ) {
+ binding =
+ DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_cart_recommendation,
+ container,
+ false,
+ )
+ binding.apply {
+ lifecycleOwner = viewLifecycleOwner
+ recyclerViewCartRecommendation.adapter = adapter
+ vm = viewModel
+ }
+ }
+
+ private fun observeData() {
+ viewModel.recommendedProducts.observe(viewLifecycleOwner) { products ->
+ (binding.recyclerViewCartRecommendation.adapter as ProductAdapter).setItems(
+ products.map {
+ ProductItem(
+ it,
+ )
+ },
+ )
+ }
+ viewModel.updatedItem.observe(viewLifecycleOwner) { product ->
+ if (product != null) {
+ (binding.recyclerViewCartRecommendation.adapter as ProductAdapter).updateItem(
+ product,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt
new file mode 100644
index 000000000..80674e6d0
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt
@@ -0,0 +1,113 @@
+package woowacourse.shopping.cart.recoomendation
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import woowacourse.shopping.data.repository.CartRecommendationRepository
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class RecommendationFragmentViewModel(
+ private val cartRecommendationRepository: CartRecommendationRepository,
+) : ViewModel() {
+ private val _recommendedProducts = MutableLiveData>(emptyList())
+ val recommendedProducts: LiveData> get() = _recommendedProducts
+
+ private val _totalPurchasePrice = MutableLiveData(INITIAL_TOTAL_PURCHASE_PRICE)
+ val totalPurchasePrice: LiveData get() = _totalPurchasePrice
+
+ private val _selectedProductsCount = MutableLiveData(INITIAL_SELECTED_PRODUCTS_COUNT)
+ val selectedProductsCount: LiveData get() = _selectedProductsCount
+
+ private val _updatedItem = MutableLiveData()
+ val updatedItem: LiveData = _updatedItem
+
+ init {
+ loadRecentlyViewedProduct()
+ }
+
+ fun loadRecentlyViewedProduct() {
+ cartRecommendationRepository.getRecommendedProducts { products ->
+ _recommendedProducts.postValue(
+ products,
+ )
+ }
+ }
+
+ fun fetchTotalCount() {
+ cartRecommendationRepository.getSelectedProductsCount { count ->
+ _selectedProductsCount.postValue(count)
+ }
+ }
+
+ fun fetchPurchasePrice() {
+ val amount =
+ _recommendedProducts.value?.sumOf { it.price * it.quantity }
+ ?: INITIAL_TOTAL_PURCHASE_PRICE
+ _totalPurchasePrice.postValue(amount)
+ }
+
+ fun increaseQuantity(product: ProductUiModel) {
+ when (product.quantity) {
+ INITIAL_PRODUCT_COUNT -> {
+ val newProduct = product.copy(quantity = A_COUNT)
+ cartRecommendationRepository.insertCartProduct(newProduct) { product ->
+ updateItem(product)
+ }
+ }
+
+ else -> {
+ cartRecommendationRepository.updateCartProduct(
+ product,
+ product.quantity + A_COUNT,
+ ) { success ->
+ if (success) {
+ val newProduct = product.copy(quantity = product.quantity + A_COUNT)
+ updateItem(newProduct)
+ }
+ }
+ }
+ }
+ }
+
+ fun decreaseQuantity(product: ProductUiModel) {
+ when (product.quantity) {
+ A_COUNT -> {
+ val newProduct = product.copy(quantity = INITIAL_PRODUCT_COUNT)
+ cartRecommendationRepository.deleteCartProduct(product) { success ->
+ if (success) {
+ updateItem(newProduct)
+ }
+ }
+ }
+
+ else -> {
+ val newProduct = product.copy(quantity = product.quantity - A_COUNT)
+ cartRecommendationRepository.updateCartProduct(
+ product,
+ product.quantity - A_COUNT,
+ ) { success ->
+ if (success) {
+ updateItem(newProduct)
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateItem(newProduct: ProductUiModel) {
+ _updatedItem.postValue(newProduct)
+ _recommendedProducts.value =
+ _recommendedProducts.value?.map {
+ if (it.id == newProduct.id) newProduct else it
+ }
+ fetchTotalCount()
+ fetchPurchasePrice()
+ }
+
+ companion object {
+ private const val INITIAL_TOTAL_PURCHASE_PRICE: Int = 0
+ private const val INITIAL_SELECTED_PRODUCTS_COUNT: Int = 0
+ private const val INITIAL_PRODUCT_COUNT: Int = 0
+ private const val A_COUNT: Int = 1
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt
new file mode 100644
index 000000000..6c84f3d8a
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt
@@ -0,0 +1,34 @@
+package woowacourse.shopping.cart.recoomendation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.data.repository.CartRecommendationRepository
+import woowacourse.shopping.data.repository.CartRecommendationRepositoryImpl
+import woowacourse.shopping.data.source.CartProductRemoteDataSource
+import woowacourse.shopping.data.source.CatalogProductDataSourceRemoteDataSource
+import woowacourse.shopping.data.source.RecentlyViewedProductDataSourceRemoteDataSource
+
+@Suppress("UNCHECKED_CAST")
+class RecommendationFragmentViewModelFactory(
+ private val application: ShoppingApplication,
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(RecommendationFragmentViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ val cartRecommendationRepository: CartRecommendationRepository =
+ CartRecommendationRepositoryImpl.initialize(
+ cartProductDataSource = CartProductRemoteDataSource(),
+ catalogProductDataSource = CatalogProductDataSourceRemoteDataSource(),
+ recentlyViewProductDataSource =
+ RecentlyViewedProductDataSourceRemoteDataSource.initialize(
+ application.database.recentlyViewedProductDao(),
+ ),
+ )
+ return RecommendationFragmentViewModel(
+ cartRecommendationRepository = cartRecommendationRepository,
+ ) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt b/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt
new file mode 100644
index 000000000..ff9391446
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt
@@ -0,0 +1,88 @@
+package woowacourse.shopping.cart.selection
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.R
+import woowacourse.shopping.cart.CartAdapter
+import woowacourse.shopping.cart.CartItem
+import woowacourse.shopping.cart.CartViewModel
+import woowacourse.shopping.cart.CartViewModelFactory
+import woowacourse.shopping.cart.DeleteProductClickListener
+import woowacourse.shopping.databinding.FragmentCartSelectionBinding
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class SelectionFragment : Fragment() {
+ private lateinit var binding: FragmentCartSelectionBinding
+ private val viewModel: CartViewModel by lazy {
+ ViewModelProvider(
+ this,
+ CartViewModelFactory(),
+ )[CartViewModel::class.java]
+ }
+ private val adapter: CartAdapter by lazy {
+ CartAdapter(
+ cartItems = emptyList(),
+ onDeleteProductClick =
+ DeleteProductClickListener { product ->
+ viewModel.deleteCartProduct(CartItem.ProductItem(product))
+ },
+ onPaginationButtonClick = viewModel::onPaginationButtonClick,
+ quantityControlListener = viewModel::updateQuantity,
+ onCheckClick = viewModel::changeProductSelection,
+ )
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ binding =
+ DataBindingUtil.inflate(inflater, R.layout.fragment_cart_selection, container, false)
+ binding.apply {
+ lifecycleOwner = this@SelectionFragment
+ recyclerViewCart.adapter = adapter
+ vm = viewModel
+ }
+ return binding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ observeCartViewModel()
+ }
+
+ private fun observeCartViewModel() {
+ viewModel.apply {
+ cartProducts.observe(viewLifecycleOwner) {
+ updateCartItems()
+ }
+ isNextButtonEnabled.observe(viewLifecycleOwner) { updateCartItems() }
+ isPrevButtonEnabled.observe(viewLifecycleOwner) { updateCartItems() }
+ page.observe(viewLifecycleOwner) { updateCartItems() }
+ updatedItem.observe(viewLifecycleOwner) { item ->
+ (binding.recyclerViewCart.adapter as CartAdapter).setCartItem(item)
+ }
+ }
+ }
+
+ private fun updateCartItems() {
+ val products: List = viewModel.cartProducts.value ?: return
+ val isNext: Boolean = viewModel.isNextButtonEnabled.value == true
+ val isPrev: Boolean = viewModel.isPrevButtonEnabled.value == true
+ val page: Int = viewModel.page.value ?: 0
+ val paginationItem = CartItem.PaginationButtonItem(page + 1, isNext, isPrev)
+ val cartItems: List = products.map { CartItem.ProductItem(it) }
+ val cartItemsWithPaginationBtn =
+ if (cartItems.isEmpty()) cartItems else cartItems + paginationItem
+ (binding.recyclerViewCart.adapter as CartAdapter).setCartItems(cartItemsWithPaginationBtn)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt b/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt
new file mode 100644
index 000000000..7cbf9492f
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt
@@ -0,0 +1,44 @@
+package woowacourse.shopping.data.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy.Companion.REPLACE
+import androidx.room.Query
+import woowacourse.shopping.data.entity.CartProductEntity
+
+@Dao
+interface CartProductDao {
+ @Insert(onConflict = REPLACE)
+ fun insertCartProduct(cartProduct: CartProductEntity)
+
+ @Delete
+ fun deleteCartProduct(cartProduct: CartProductEntity)
+
+ @Query("SELECT * FROM CartProducts WHERE uid = :id")
+ fun getCartProduct(id: Int): CartProductEntity?
+
+ @Query("SELECT * FROM CartProducts LIMIT :endIndex OFFSET :startIndex")
+ fun getCartProductsInRange(
+ startIndex: Int,
+ endIndex: Int,
+ ): List
+
+ @Query("SELECT * FROM CartProducts WHERE uid IN (:uids)")
+ fun getCartProductsByUids(uids: List): List
+
+ @Query("UPDATE CartProducts SET quantity = quantity + :diff WHERE uid = :id")
+ fun updateProduct(
+ id: Int,
+ diff: Int,
+ )
+
+ @Query("SELECT quantity FROM CartProducts WHERE uid = :uid")
+ fun getProductQuantity(uid: Int): Int?
+
+ @Query("SELECT COUNT(*) FROM CartProducts")
+ fun getAllProductsSize(): Int
+
+ @Query("SELECT SUM(quantity) FROM CartProducts")
+ fun getCartItemSize(): Int
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt b/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt
new file mode 100644
index 000000000..6ea42c1c7
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt
@@ -0,0 +1,19 @@
+package woowacourse.shopping.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy.Companion.REPLACE
+import androidx.room.Query
+import woowacourse.shopping.data.entity.RecentlyViewedProductEntity
+
+@Dao
+interface RecentlyViewedProductDao {
+ @Insert(onConflict = REPLACE)
+ fun insertRecentlyViewedProductUid(recentlyViewedProductEntity: RecentlyViewedProductEntity)
+
+ @Query("SELECT productUid FROM RecentlyViewedProducts ORDER BY timestamp DESC")
+ fun getRecentlyViewedProductUids(): List
+
+ @Query("SELECT productUid FROM RecentlyViewedProducts ORDER BY timestamp DESC LIMIT 1")
+ fun getLatestViewedProductUid(): Int
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt b/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt
new file mode 100644
index 000000000..9bb4e983b
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt
@@ -0,0 +1,37 @@
+package woowacourse.shopping.data.database
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import woowacourse.shopping.data.dao.CartProductDao
+import woowacourse.shopping.data.dao.RecentlyViewedProductDao
+import woowacourse.shopping.data.entity.CartProductEntity
+import woowacourse.shopping.data.entity.RecentlyViewedProductEntity
+
+@Database(entities = [CartProductEntity::class, RecentlyViewedProductEntity::class], version = 1)
+abstract class ShoppingDatabase : RoomDatabase() {
+ abstract fun cartProductDao(): CartProductDao
+
+ abstract fun recentlyViewedProductDao(): RecentlyViewedProductDao
+
+ companion object {
+ @Volatile
+ private var iNSTANCE: ShoppingDatabase? = null
+
+ fun getInstance(context: Context): ShoppingDatabase =
+ iNSTANCE ?: synchronized(this) {
+ val instance =
+ Room
+ .databaseBuilder(
+ context.applicationContext,
+ ShoppingDatabase::class.java,
+ "app_database",
+ ).fallbackToDestructiveMigration()
+ .build()
+
+ iNSTANCE = instance
+ instance
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt
new file mode 100644
index 000000000..c89c3ad60
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt
@@ -0,0 +1,12 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class Content(
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("product")
+ val product: Product,
+ @SerializedName("quantity")
+ val quantity: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt
new file mode 100644
index 000000000..62478d512
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt
@@ -0,0 +1,18 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class Pageable(
+ @SerializedName("offset")
+ val offset: Long,
+ @SerializedName("pageNumber")
+ val pageNumber: Int,
+ @SerializedName("pageSize")
+ val pageSize: Int,
+ @SerializedName("paged")
+ val paged: Boolean,
+ @SerializedName("sort")
+ val sort: Sort,
+ @SerializedName("unpaged")
+ val unpaged: Boolean,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt
new file mode 100644
index 000000000..f404da982
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt
@@ -0,0 +1,16 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class Product(
+ @SerializedName("category")
+ val category: String,
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("imageUrl")
+ val imageUrl: String,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("price")
+ val price: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt
new file mode 100644
index 000000000..780ee90c3
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt
@@ -0,0 +1,28 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class ProductResponse(
+ @SerializedName("content")
+ val content: List,
+ @SerializedName("empty")
+ val empty: Boolean,
+ @SerializedName("first")
+ val first: Boolean,
+ @SerializedName("last")
+ val last: Boolean,
+ @SerializedName("number")
+ val number: Int,
+ @SerializedName("numberOfElements")
+ val numberOfElements: Int,
+ @SerializedName("pageable")
+ val pageable: Pageable,
+ @SerializedName("size")
+ val size: Int,
+ @SerializedName("sort")
+ val sort: Sort,
+ @SerializedName("totalElements")
+ val totalElements: Long,
+ @SerializedName("totalPages")
+ val totalPages: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt
new file mode 100644
index 000000000..6ca4759c8
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt
@@ -0,0 +1,8 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class Quantity(
+ @SerializedName("quantity")
+ val value: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt
new file mode 100644
index 000000000..522c50b5f
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt
@@ -0,0 +1,12 @@
+package woowacourse.shopping.data.dto.cartitem
+
+import com.google.gson.annotations.SerializedName
+
+data class Sort(
+ @SerializedName("empty")
+ val empty: Boolean,
+ @SerializedName("sorted")
+ val sorted: Boolean,
+ @SerializedName("unsorted")
+ val unsorted: Boolean,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt
new file mode 100644
index 000000000..1698ef56b
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt
@@ -0,0 +1,6 @@
+package woowacourse.shopping.data.dto.cartitem
+
+data class UpdateCartItemRequest(
+ val productId: Int,
+ val quantity: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt
new file mode 100644
index 000000000..349cd7129
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt
@@ -0,0 +1,16 @@
+package woowacourse.shopping.data.dto.product
+
+import com.google.gson.annotations.SerializedName
+
+data class Content(
+ @SerializedName("category")
+ val category: String,
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("imageUrl")
+ val imageUrl: String,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("price")
+ val price: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt
new file mode 100644
index 000000000..f19aef173
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt
@@ -0,0 +1,18 @@
+package woowacourse.shopping.data.dto.product
+
+import com.google.gson.annotations.SerializedName
+
+data class Pageable(
+ @SerializedName("offset")
+ val offset: Long,
+ @SerializedName("pageNumber")
+ val pageNumber: Int,
+ @SerializedName("pageSize")
+ val pageSize: Int,
+ @SerializedName("paged")
+ val paged: Boolean,
+ @SerializedName("sort")
+ val sort: Sort,
+ @SerializedName("unpaged")
+ val unpaged: Boolean,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt
new file mode 100644
index 000000000..60b564690
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt
@@ -0,0 +1,28 @@
+package woowacourse.shopping.data.dto.product
+
+import com.google.gson.annotations.SerializedName
+
+data class ProductResponse(
+ @SerializedName("content")
+ val content: List,
+ @SerializedName("empty")
+ val empty: Boolean,
+ @SerializedName("first")
+ val first: Boolean,
+ @SerializedName("last")
+ val last: Boolean,
+ @SerializedName("number")
+ val number: Int,
+ @SerializedName("numberOfElements")
+ val numberOfElements: Int,
+ @SerializedName("pageable")
+ val pageable: Pageable,
+ @SerializedName("size")
+ val size: Int,
+ @SerializedName("sort")
+ val sort: Sort,
+ @SerializedName("totalElements")
+ val totalElements: Long,
+ @SerializedName("totalPages")
+ val totalPages: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt
new file mode 100644
index 000000000..3a91d02f0
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt
@@ -0,0 +1,12 @@
+package woowacourse.shopping.data.dto.product
+
+import com.google.gson.annotations.SerializedName
+
+data class Sort(
+ @SerializedName("empty")
+ val empty: Boolean,
+ @SerializedName("sorted")
+ val sorted: Boolean,
+ @SerializedName("unsorted")
+ val unsorted: Boolean,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt b/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt
new file mode 100644
index 000000000..98674fb95
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt
@@ -0,0 +1,13 @@
+package woowacourse.shopping.data.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "CartProducts")
+data class CartProductEntity(
+ @PrimaryKey(autoGenerate = true) val uid: Int,
+ val imageUrl: String,
+ val name: String,
+ val price: Int,
+ val quantity: Int,
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt b/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt
new file mode 100644
index 000000000..beae18f75
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt
@@ -0,0 +1,10 @@
+package woowacourse.shopping.data.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "RecentlyViewedProducts")
+class RecentlyViewedProductEntity(
+ @PrimaryKey val productUid: Int,
+ val timestamp: Long = System.currentTimeMillis(),
+)
diff --git a/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt b/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt
new file mode 100644
index 000000000..20df99715
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt
@@ -0,0 +1,27 @@
+package woowacourse.shopping.data.mapper
+
+import woowacourse.shopping.data.entity.CartProductEntity
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+fun CartProductEntity.toUiModel(): ProductUiModel =
+ with(this) {
+ ProductUiModel(
+ uid,
+ imageUrl,
+ name,
+ price,
+ quantity,
+ 0,
+ )
+ }
+
+fun ProductUiModel.toEntity(): CartProductEntity =
+ with(this) {
+ CartProductEntity(
+ id,
+ imageUrl,
+ name,
+ price,
+ quantity,
+ )
+ }
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt
new file mode 100644
index 000000000..cdbe0e726
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt
@@ -0,0 +1,36 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CartProductRepository {
+ fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ )
+
+ fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun updateProduct(
+ cartProduct: ProductUiModel,
+ quantity: Int,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun getCartItemSize(callback: (Int) -> Unit)
+
+ fun getTotalElements(callback: (Int) -> Unit)
+
+ fun getCartProducts(
+ totalElements: Int,
+ callback: (List) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt
new file mode 100644
index 000000000..3715633dd
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt
@@ -0,0 +1,25 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CartRecommendationRepository {
+ fun getRecommendedProducts(callback: (List) -> Unit)
+
+ fun getSelectedProductsCount(callback: (Int) -> Unit)
+
+ fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ )
+
+ fun updateCartProduct(
+ cartProduct: ProductUiModel,
+ newCount: Int,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt
new file mode 100644
index 000000000..9621bc7ed
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt
@@ -0,0 +1,80 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.source.CartProductDataSource
+import woowacourse.shopping.data.source.CatalogProductDataSource
+import woowacourse.shopping.data.source.RecentlyViewedProductDataSource
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class CartRecommendationRepositoryImpl(
+ private val cartProductDataSource: CartProductDataSource,
+ private val catalogProductDataSource: CatalogProductDataSource,
+ private val recentlyViewProductDataSource: RecentlyViewedProductDataSource,
+) : CartRecommendationRepository {
+ override fun getRecommendedProducts(callback: (List) -> Unit) {
+ recentlyViewProductDataSource.getLatestViewedProduct { id ->
+ catalogProductDataSource.getProduct(id) { categoryProduct ->
+ val category = categoryProduct.category ?: ""
+ catalogProductDataSource.getRecommendedProducts(
+ category,
+ ZERO,
+ TOTAL_RECOMMENDATION_PRODUCTS_COUNT,
+ ) { products ->
+ callback(products)
+ }
+ }
+ }
+ }
+
+ override fun getSelectedProductsCount(callback: (Int) -> Unit) {
+ cartProductDataSource.getTotalElements { count ->
+ callback(count)
+ }
+ }
+
+ override fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ ) {
+ cartProductDataSource.insertCartProduct(cartProduct) { product ->
+ callback(product)
+ }
+ }
+
+ override fun updateCartProduct(
+ cartProduct: ProductUiModel,
+ newCount: Int,
+ callback: (Boolean) -> Unit,
+ ) {
+ cartProductDataSource.updateProduct(cartProduct, newCount) { result ->
+ callback(result)
+ }
+ }
+
+ override fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ ) {
+ cartProductDataSource.deleteCartProduct(cartProduct) { result ->
+ callback(result)
+ }
+ }
+
+ companion object {
+ private const val ZERO: Int = 0
+ private const val TOTAL_RECOMMENDATION_PRODUCTS_COUNT: Int = 10
+
+ private var instance: CartRecommendationRepository? = null
+
+ @Synchronized
+ fun initialize(
+ cartProductDataSource: CartProductDataSource,
+ catalogProductDataSource: CatalogProductDataSource,
+ recentlyViewProductDataSource: RecentlyViewedProductDataSource,
+ ): CartRecommendationRepository =
+ instance ?: CartRecommendationRepositoryImpl(
+ cartProductDataSource,
+ catalogProductDataSource,
+ recentlyViewProductDataSource,
+ ).also { instance = it }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt
new file mode 100644
index 000000000..257006efc
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt
@@ -0,0 +1,24 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CartRepository {
+ fun getTotalProductsCount(callback: (Int) -> Unit)
+
+ fun updateCartProduct(
+ cartProduct: ProductUiModel,
+ newCount: Int,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt
new file mode 100644
index 000000000..e64736dce
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt
@@ -0,0 +1,53 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.source.CartProductDataSource
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class CartRepositoryImpl(
+ private val cartProductDataSource: CartProductDataSource,
+) : CartRepository {
+ override fun getTotalProductsCount(callback: (Int) -> Unit) {
+ cartProductDataSource.getTotalElements { count ->
+ callback(count)
+ }
+ }
+
+ override fun updateCartProduct(
+ cartProduct: ProductUiModel,
+ newCount: Int,
+ callback: (Boolean) -> Unit,
+ ) {
+ cartProductDataSource.updateProduct(cartProduct, newCount) { result ->
+ callback(result)
+ }
+ }
+
+ override fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ ) {
+ cartProductDataSource.deleteCartProduct(cartProduct) { result ->
+ callback(result)
+ }
+ }
+
+ override fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ ) {
+ cartProductDataSource.getCartProductsInRange(currentPage, pageSize) { products ->
+ callback(products)
+ }
+ }
+
+ companion object {
+ private var instance: CartRepositoryImpl? = null
+
+ @Synchronized
+ fun initialize(cartProductDataSource: CartProductDataSource): CartRepositoryImpl =
+ instance ?: CartRepositoryImpl(
+ cartProductDataSource,
+ ).also { instance = it }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt
new file mode 100644
index 000000000..7a2546f20
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt
@@ -0,0 +1,30 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CatalogProductRepository {
+ fun getRecommendedProducts(
+ category: String,
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun getAllProductsSize(callback: (Int) -> Unit)
+
+ fun getCartProductsByUids(
+ uids: List,
+ callback: (List) -> Unit,
+ )
+
+ fun getProductsByPage(
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun getProduct(
+ id: Int,
+ callback: (ProductUiModel) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt
new file mode 100644
index 000000000..05238703d
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt
@@ -0,0 +1,12 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.entity.CartProductEntity
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface RecentlyViewedProductRepository {
+ fun insertRecentlyViewedProductUid(uid: Int)
+
+ fun getRecentlyViewedProducts(callback: (List) -> Unit)
+
+ fun getLatestViewedProduct(callback: (ProductUiModel) -> Unit)
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt
new file mode 100644
index 000000000..65231bc30
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt
@@ -0,0 +1,38 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.dao.RecentlyViewedProductDao
+import woowacourse.shopping.data.entity.CartProductEntity
+import woowacourse.shopping.data.entity.RecentlyViewedProductEntity
+import woowacourse.shopping.data.mapper.toEntity
+import woowacourse.shopping.product.catalog.ProductUiModel
+import kotlin.concurrent.thread
+
+class RecentlyViewedProductRepositoryImpl(
+ val recentlyViewedProductDao: RecentlyViewedProductDao,
+ val catalogProductRepository: CatalogProductRepository,
+) : RecentlyViewedProductRepository {
+ override fun insertRecentlyViewedProductUid(uid: Int) {
+ thread {
+ recentlyViewedProductDao.insertRecentlyViewedProductUid(RecentlyViewedProductEntity(uid))
+ }
+ }
+
+ override fun getRecentlyViewedProducts(callback: (List) -> Unit) {
+ thread {
+ val uids = recentlyViewedProductDao.getRecentlyViewedProductUids()
+ catalogProductRepository.getCartProductsByUids(uids) { products ->
+ val entities = products.map { it.toEntity() }
+ callback(entities)
+ }
+ }
+ }
+
+ override fun getLatestViewedProduct(callback: (ProductUiModel) -> Unit) {
+ thread {
+ val uid = recentlyViewedProductDao.getLatestViewedProductUid()
+ catalogProductRepository.getProduct(uid) { product ->
+ callback(product)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt
new file mode 100644
index 000000000..013c0ec91
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt
@@ -0,0 +1,250 @@
+package woowacourse.shopping.data.repository
+
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import woowacourse.shopping.data.dto.cartitem.Content
+import woowacourse.shopping.data.dto.cartitem.ProductResponse
+import woowacourse.shopping.data.dto.cartitem.Quantity
+import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest
+import woowacourse.shopping.data.service.CartItemService
+import woowacourse.shopping.data.service.RetrofitProductService
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class RemoteCartProductRepositoryImpl : CartProductRepository {
+ val retrofitService = RetrofitProductService.INSTANCE.create(CartItemService::class.java)
+
+ override fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ ) {
+ retrofitService
+ .postCartItems(
+ request =
+ UpdateCartItemRequest(
+ productId = cartProduct.id,
+ quantity = cartProduct.quantity,
+ ),
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val locationHeader = response.headers()["location"]
+ val id = locationHeader?.substringAfterLast("/")?.toIntOrNull()
+ callback(cartProduct.copy(cartItemId = id))
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ ) {
+ if (cartProduct.cartItemId != null) {
+ retrofitService.deleteCartItem(cartItemId = cartProduct.cartItemId).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ callback(true)
+ } else {
+ callback(false)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+ }
+
+ override fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestCartItems(
+ page = currentPage,
+ size = pageSize,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List = body?.content ?: return
+ val products: List =
+ content.map {
+ ProductUiModel(
+ id = it.product.id.toInt(),
+ imageUrl = it.product.imageUrl,
+ name = it.product.name,
+ price = it.product.price,
+ cartItemId = it.id.toInt(),
+ quantity = it.quantity,
+ )
+ }
+ callback(products)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun updateProduct(
+ cartProduct: ProductUiModel,
+ quantity: Int,
+ callback: (Boolean) -> Unit,
+ ) {
+ retrofitService
+ .patchCartItemQuantity(
+ cartItemId = cartProduct.cartItemId!!,
+ quantity = Quantity(quantity),
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ callback(true)
+ } else {
+ callback(false)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ callback(false)
+ }
+ },
+ )
+ }
+
+ override fun getCartItemSize(callback: (Int) -> Unit) {
+ retrofitService.getCartItemsCount().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: Quantity = response.body() ?: return
+ callback(body.value)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getTotalElements(callback: (Int) -> Unit) {
+ retrofitService
+ .requestCartItems(
+ page = 0,
+ size = 1,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse = response.body() ?: return
+ val totalElements = body.totalElements.toInt()
+ callback(totalElements)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getCartProducts(
+ totalElements: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestCartItems(
+ page = 0,
+ size = totalElements,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse = response.body() ?: return
+ val content: List = body.content
+ val products: List =
+ content.map {
+ ProductUiModel(
+ id = it.product.id.toInt(),
+ imageUrl = it.product.imageUrl,
+ name = it.product.name,
+ price = it.product.price,
+ quantity = it.quantity,
+ cartItemId = it.id.toInt(),
+ )
+ }
+ callback(products)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt
new file mode 100644
index 000000000..dcc71a1d1
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt
@@ -0,0 +1,187 @@
+package woowacourse.shopping.data.repository
+
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import woowacourse.shopping.data.dto.product.Content
+import woowacourse.shopping.data.dto.product.ProductResponse
+import woowacourse.shopping.data.service.ProductService
+import woowacourse.shopping.data.service.RetrofitProductService
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class RemoteCatalogProductRepositoryImpl : CatalogProductRepository {
+ val retrofitService = RetrofitProductService.INSTANCE.create(ProductService::class.java)
+
+ override fun getRecommendedProducts(
+ category: String,
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestProducts(
+ category = category,
+ page = page,
+ size = size,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List? = body?.content
+ val products: List? =
+ content?.mapNotNull {
+ ProductUiModel(
+ id = it.id.toInt(),
+ imageUrl = it.imageUrl,
+ name = it.name,
+ price = it.price,
+ )
+ }
+ callback(products ?: emptyList())
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getAllProductsSize(callback: (Int) -> Unit) {
+ retrofitService.requestProducts().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ callback(body?.totalElements?.toInt() ?: 0)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getCartProductsByUids(
+ uids: List,
+ callback: (List) -> Unit,
+ ) {
+ val resultsMap = mutableMapOf()
+ var completedCount = 0
+
+ if (uids.isEmpty()) {
+ callback(emptyList())
+ return
+ }
+
+ uids.forEach { uid ->
+ getProduct(uid) { product ->
+ resultsMap[uid] = product
+ completedCount++
+
+ if (completedCount == uids.size) {
+ val orderedResults = uids.mapNotNull { resultsMap[it] }
+ callback(orderedResults)
+ }
+ }
+ }
+ }
+
+ override fun getProductsByPage(
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestProducts(
+ page = page,
+ size = size,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List? = body?.content
+ val products: List? =
+ content?.mapNotNull {
+ ProductUiModel(
+ id = it.id.toInt(),
+ imageUrl = it.imageUrl,
+ name = it.name,
+ price = it.price,
+ )
+ }
+ callback(products ?: emptyList())
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getProduct(
+ id: Int,
+ callback: (ProductUiModel) -> Unit,
+ ) {
+ retrofitService
+ .requestDetailProduct(
+ id = id,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: Content = response.body() ?: return
+ val product =
+ ProductUiModel(
+ id = body.id.toInt(),
+ imageUrl = body.imageUrl,
+ name = body.name,
+ price = body.price,
+ category = body.category,
+ )
+ callback(product)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt b/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt
new file mode 100644
index 000000000..9e1b4f38a
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt
@@ -0,0 +1,48 @@
+package woowacourse.shopping.data.service
+
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+import woowacourse.shopping.data.dto.cartitem.ProductResponse
+import woowacourse.shopping.data.dto.cartitem.Quantity
+import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest
+
+interface CartItemService {
+ @GET("/cart-items")
+ fun requestCartItems(
+ @Header("accept") accept: String = "*/*",
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 1,
+ @Query("sort") sort: List = listOf(),
+ ): Call
+
+ @POST("/cart-items")
+ fun postCartItems(
+ @Header("accept") accept: String = "*/*",
+ @Body request: UpdateCartItemRequest,
+ ): Call
+
+ @DELETE("/cart-items/{id}")
+ fun deleteCartItem(
+ @Header("accept") accept: String = "*/*",
+ @Path("id") cartItemId: Int,
+ ): Call
+
+ @PATCH("/cart-items/{id}")
+ fun patchCartItemQuantity(
+ @Header("accept") accept: String = "*/*",
+ @Path("id") cartItemId: Int,
+ @Body quantity: Quantity,
+ ): Call
+
+ @GET("/cart-items/counts")
+ fun getCartItemsCount(
+ @Header("accept") accept: String = "*/*",
+ ): Call
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt b/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt
new file mode 100644
index 000000000..e97e2ad4e
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt
@@ -0,0 +1,25 @@
+package woowacourse.shopping.data.service
+
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class Interceptor(
+ private val base64: String?,
+ private val isAuth: Boolean = false,
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originRequest = chain.request()
+
+ val modifiedRequest =
+ when (isAuth) {
+ true ->
+ originRequest.newBuilder()
+ .addHeader("Authorization", "Basic $base64")
+ .build()
+ false ->
+ originRequest
+ }
+
+ return chain.proceed(modifiedRequest)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt b/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt
new file mode 100644
index 000000000..af1a75883
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt
@@ -0,0 +1,25 @@
+package woowacourse.shopping.data.service
+
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.Path
+import retrofit2.http.Query
+import woowacourse.shopping.data.dto.product.Content
+import woowacourse.shopping.data.dto.product.ProductResponse
+
+interface ProductService {
+ @GET("/products")
+ fun requestProducts(
+ @Header("accept") accept: String = "*/*",
+ @Query("category") category: String? = null,
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 1,
+ ): Call
+
+ @GET("/products/{id}")
+ fun requestDetailProduct(
+ @Header("accept") accept: String = "*/*",
+ @Path("id") id: Int = 0,
+ ): Call
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt b/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt
new file mode 100644
index 000000000..b6088e963
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt
@@ -0,0 +1,52 @@
+package woowacourse.shopping.data.service
+
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.logging.HttpLoggingInterceptor
+import okhttp3.logging.HttpLoggingInterceptor.Level
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import woowacourse.shopping.BuildConfig
+import java.util.Base64
+
+object RetrofitProductService {
+ private val logging =
+ HttpLoggingInterceptor().apply {
+ level = Level.BODY
+ }
+
+ private val base64Credentials: String by lazy {
+ val username = BuildConfig.USER_ID
+ val password = BuildConfig.USER_PASSWORD
+ val credentials = "$username:$password"
+ Base64.getEncoder().encodeToString(credentials.toByteArray())
+ }
+
+ private val authInterceptor =
+ Interceptor { chain ->
+ val original: Request = chain.request()
+ val requestWithAuth: Request =
+ original
+ .newBuilder()
+ .header("Authorization", "Basic $base64Credentials")
+ .build()
+ chain.proceed(requestWithAuth)
+ }
+
+ private val okHttpClient =
+ OkHttpClient
+ .Builder()
+ .addInterceptor(authInterceptor)
+ .addInterceptor(logging)
+ .build()
+
+ val INSTANCE: Retrofit by lazy {
+ Retrofit
+ .Builder()
+ .client(okHttpClient)
+ .baseUrl(BuildConfig.BASE_URL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt
new file mode 100644
index 000000000..055c5b5c7
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt
@@ -0,0 +1,36 @@
+package woowacourse.shopping.data.source
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CartProductDataSource {
+ fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ )
+
+ fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun updateProduct(
+ cartProduct: ProductUiModel,
+ quantity: Int,
+ callback: (Boolean) -> Unit,
+ )
+
+ fun getCartItemSize(callback: (Int) -> Unit)
+
+ fun getTotalElements(callback: (Int) -> Unit)
+
+ fun getCartProducts(
+ totalElements: Int,
+ callback: (List) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt
new file mode 100644
index 000000000..ff73c4203
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt
@@ -0,0 +1,254 @@
+package woowacourse.shopping.data.source
+
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import woowacourse.shopping.data.dto.cartitem.Content
+import woowacourse.shopping.data.dto.cartitem.ProductResponse
+import woowacourse.shopping.data.dto.cartitem.Quantity
+import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest
+import woowacourse.shopping.data.service.CartItemService
+import woowacourse.shopping.data.service.RetrofitProductService
+import woowacourse.shopping.product.catalog.ProductUiModel
+import kotlin.text.substringAfterLast
+
+class CartProductRemoteDataSource(
+ private val retrofitService: CartItemService =
+ RetrofitProductService.INSTANCE.create(
+ CartItemService::class.java,
+ ),
+) : CartProductDataSource {
+ override fun insertCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (ProductUiModel) -> Unit,
+ ) {
+ retrofitService
+ .postCartItems(
+ request =
+ UpdateCartItemRequest(
+ productId = cartProduct.id,
+ quantity = cartProduct.quantity,
+ ),
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val locationHeader = response.headers()["location"]
+ val id = locationHeader?.substringAfterLast("/")?.toIntOrNull()
+ callback(cartProduct.copy(cartItemId = id))
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun deleteCartProduct(
+ cartProduct: ProductUiModel,
+ callback: (Boolean) -> Unit,
+ ) {
+ if (cartProduct.cartItemId != null) {
+ retrofitService.deleteCartItem(cartItemId = cartProduct.cartItemId).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ callback(true)
+ } else {
+ callback(false)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+ }
+
+ override fun getCartProductsInRange(
+ currentPage: Int,
+ pageSize: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestCartItems(
+ page = currentPage,
+ size = pageSize,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List = body?.content ?: return
+ val products: List =
+ content.map {
+ ProductUiModel(
+ id = it.product.id.toInt(),
+ imageUrl = it.product.imageUrl,
+ name = it.product.name,
+ price = it.product.price,
+ cartItemId = it.id.toInt(),
+ quantity = it.quantity,
+ )
+ }
+ callback(products)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun updateProduct(
+ cartProduct: ProductUiModel,
+ quantity: Int,
+ callback: (Boolean) -> Unit,
+ ) {
+ retrofitService
+ .patchCartItemQuantity(
+ cartItemId = cartProduct.cartItemId!!,
+ quantity = Quantity(quantity),
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ callback(true)
+ } else {
+ callback(false)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ callback(false)
+ }
+ },
+ )
+ }
+
+ override fun getCartItemSize(callback: (Int) -> Unit) {
+ retrofitService.getCartItemsCount().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: Quantity = response.body() ?: return
+ callback(body.value)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getTotalElements(callback: (Int) -> Unit) {
+ retrofitService
+ .requestCartItems(
+ page = 0,
+ size = 1,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse = response.body() ?: return
+ val totalElements = body.totalElements.toInt()
+ callback(totalElements)
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getCartProducts(
+ totalElements: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestCartItems(
+ page = 0,
+ size = totalElements,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse = response.body() ?: return
+ val content: List = body.content
+ val products: List =
+ content.map {
+ ProductUiModel(
+ id = it.product.id.toInt(),
+ imageUrl = it.product.imageUrl,
+ name = it.product.name,
+ price = it.product.price,
+ quantity = it.quantity,
+ cartItemId = it.id.toInt(),
+ )
+ }
+ callback(products)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt
new file mode 100644
index 000000000..e082f2ac7
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt
@@ -0,0 +1,30 @@
+package woowacourse.shopping.data.source
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+interface CatalogProductDataSource {
+ fun getRecommendedProducts(
+ category: String,
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun getAllProductsSize(callback: (Int) -> Unit)
+
+ fun getCartProductsByUids(
+ uids: List,
+ callback: (List) -> Unit,
+ )
+
+ fun getProductsByPage(
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ )
+
+ fun getProduct(
+ id: Int,
+ callback: (ProductUiModel) -> Unit,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt
new file mode 100644
index 000000000..275406a3c
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt
@@ -0,0 +1,190 @@
+package woowacourse.shopping.data.source
+
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import woowacourse.shopping.data.dto.product.Content
+import woowacourse.shopping.data.dto.product.ProductResponse
+import woowacourse.shopping.data.service.ProductService
+import woowacourse.shopping.data.service.RetrofitProductService
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class CatalogProductDataSourceRemoteDataSource(
+ private val retrofitService: ProductService =
+ RetrofitProductService.INSTANCE.create(
+ ProductService::class.java,
+ ),
+) : CatalogProductDataSource {
+ override fun getRecommendedProducts(
+ category: String,
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestProducts(
+ category = category,
+ page = page,
+ size = size,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List? = body?.content
+ val products: List? =
+ content?.mapNotNull {
+ ProductUiModel(
+ id = it.id.toInt(),
+ imageUrl = it.imageUrl,
+ name = it.name,
+ price = it.price,
+ )
+ }
+ callback(products ?: emptyList())
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getAllProductsSize(callback: (Int) -> Unit) {
+ retrofitService.requestProducts().enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ callback(body?.totalElements?.toInt() ?: 0)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getCartProductsByUids(
+ uids: List,
+ callback: (List) -> Unit,
+ ) {
+ val resultsMap = mutableMapOf()
+ var completedCount = 0
+
+ if (uids.isEmpty()) {
+ callback(emptyList())
+ return
+ }
+
+ uids.forEach { uid ->
+ getProduct(uid) { product ->
+ resultsMap[uid] = product
+ completedCount++
+
+ if (completedCount == uids.size) {
+ val orderedResults = uids.mapNotNull { resultsMap[it] }
+ callback(orderedResults)
+ }
+ }
+ }
+ }
+
+ override fun getProductsByPage(
+ page: Int,
+ size: Int,
+ callback: (List) -> Unit,
+ ) {
+ retrofitService
+ .requestProducts(
+ page = page,
+ size = size,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: ProductResponse? = response.body()
+ val content: List? = body?.content
+ val products: List? =
+ content?.mapNotNull {
+ ProductUiModel(
+ id = it.id.toInt(),
+ imageUrl = it.imageUrl,
+ name = it.name,
+ price = it.price,
+ )
+ }
+ callback(products ?: emptyList())
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+
+ override fun getProduct(
+ id: Int,
+ callback: (ProductUiModel) -> Unit,
+ ) {
+ retrofitService
+ .requestDetailProduct(
+ id = id,
+ ).enqueue(
+ object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ if (response.isSuccessful) {
+ val body: Content = response.body() ?: return
+ val product =
+ ProductUiModel(
+ id = body.id.toInt(),
+ imageUrl = body.imageUrl,
+ name = body.name,
+ price = body.price,
+ category = body.category,
+ )
+ callback(product)
+ println("body : $body")
+ }
+ }
+
+ override fun onFailure(
+ call: Call,
+ t: Throwable,
+ ) {
+ println("error : $t")
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt
new file mode 100644
index 000000000..7abf11df6
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt
@@ -0,0 +1,9 @@
+package woowacourse.shopping.data.source
+
+interface RecentlyViewedProductDataSource {
+ fun insertRecentlyViewedProductUid(uid: Int)
+
+ fun getRecentlyViewedProducts(callback: (List) -> Unit)
+
+ fun getLatestViewedProduct(callback: (Int) -> Unit)
+}
diff --git a/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt
new file mode 100644
index 000000000..abc75d097
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt
@@ -0,0 +1,39 @@
+package woowacourse.shopping.data.source
+
+import woowacourse.shopping.data.dao.RecentlyViewedProductDao
+import woowacourse.shopping.data.entity.RecentlyViewedProductEntity
+import kotlin.concurrent.thread
+
+class RecentlyViewedProductDataSourceRemoteDataSource(
+ private val recentlyViewedProductDao: RecentlyViewedProductDao,
+) : RecentlyViewedProductDataSource {
+ override fun insertRecentlyViewedProductUid(uid: Int) {
+ thread {
+ recentlyViewedProductDao.insertRecentlyViewedProductUid(RecentlyViewedProductEntity(uid))
+ }
+ }
+
+ override fun getRecentlyViewedProducts(callback: (List) -> Unit) {
+ thread {
+ val uids = recentlyViewedProductDao.getRecentlyViewedProductUids()
+ callback(uids)
+ }
+ }
+
+ override fun getLatestViewedProduct(callback: (Int) -> Unit) {
+ thread {
+ val uid = recentlyViewedProductDao.getLatestViewedProductUid()
+ callback(uid)
+ }
+ }
+
+ companion object {
+ private var instance: RecentlyViewedProductDataSourceRemoteDataSource? = null
+
+ @Synchronized
+ fun initialize(recentlyViewedProductDao: RecentlyViewedProductDao): RecentlyViewedProductDataSourceRemoteDataSource =
+ instance ?: RecentlyViewedProductDataSourceRemoteDataSource(recentlyViewedProductDao).also {
+ instance = it
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt b/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt
new file mode 100644
index 000000000..b21fc2015
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt
@@ -0,0 +1,19 @@
+package woowacourse.shopping.domain
+
+import android.view.View
+
+data class LoadingState(
+ val isLoading: Boolean,
+ val shimmerVisibility: Int,
+ val recyclerViewVisibility: Int,
+) {
+ companion object {
+ fun loading(): LoadingState {
+ return LoadingState(true, View.VISIBLE, View.GONE)
+ }
+
+ fun loaded(): LoadingState {
+ return LoadingState(false, View.GONE, View.VISIBLE)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt
new file mode 100644
index 000000000..70a5b9b45
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt
@@ -0,0 +1,164 @@
+package woowacourse.shopping.product.catalog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.View
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.GridLayoutManager
+import woowacourse.shopping.R
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.cart.CartActivity
+import woowacourse.shopping.databinding.ActivityCatalogBinding
+import woowacourse.shopping.databinding.MenuCartLayoutBinding
+import woowacourse.shopping.product.detail.DetailActivity
+
+class CatalogActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityCatalogBinding
+ private val viewModel: CatalogViewModel by lazy {
+ ViewModelProvider(
+ this,
+ CatalogViewModelFactory(application as ShoppingApplication),
+ )[CatalogViewModel::class.java]
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_catalog)
+
+ applyWindowInsets()
+
+ setProductAdapter()
+ setRecentlyViewedProductAdapter()
+ observeCatalogProducts()
+
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.loadCatalogUntilCurrentPage()
+ viewModel.fetchTotalCount()
+ viewModel.loadRecentlyViewedProducts()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.cart_menu_item, menu)
+
+ val menuItem = menu?.findItem(R.id.action_cart)
+ val binding =
+ DataBindingUtil.inflate(
+ LayoutInflater.from(this),
+ R.layout.menu_cart_layout,
+ null,
+ false,
+ )
+ binding.layoutCartMenu.setOnClickListener {
+ startActivity(CartActivity.newIntent(this))
+ }
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+
+ menuItem?.actionView = binding.root
+ return true
+ }
+
+ private fun setProductAdapter() {
+ val adapter =
+ ProductAdapter(
+ products = emptyList(),
+ productActionListener =
+ object : ProductActionListener {
+ override fun onProductClick(product: ProductUiModel) {
+ val intent = DetailActivity.newIntent(this@CatalogActivity, product)
+ startActivity(intent)
+ }
+
+ override fun onLoadButtonClick() {
+ viewModel.loadNextCatalogProducts()
+ }
+
+ override fun onQuantityAddClick(product: ProductUiModel) {
+ viewModel.increaseQuantity(product)
+ }
+ },
+ quantityControlListener = { event, product ->
+ if (event == 1) {
+ viewModel.increaseQuantity(product)
+ } else {
+ viewModel.decreaseQuantity(product)
+ }
+ },
+ )
+
+ binding.recyclerViewProducts.adapter = adapter
+ val gridLayoutManager = GridLayoutManager(this, 2)
+ gridLayoutManager.spanSizeLookup =
+ object : GridLayoutManager.SpanSizeLookup() {
+ override fun getSpanSize(position: Int): Int = spanSizeByPosition(position, adapter.itemCount)
+ }
+ binding.recyclerViewProducts.layoutManager = gridLayoutManager
+ }
+
+ private fun setRecentlyViewedProductAdapter() {
+ val adapter =
+ RecentlyViewedProductAdapter(
+ products = emptyList(),
+ ) {
+ val intent = DetailActivity.newIntent(this@CatalogActivity, it)
+ startActivity(intent)
+ }
+ binding.recyclerViewRecentlyViewedProducts.adapter = adapter
+ }
+
+ private fun observeCatalogProducts() {
+ viewModel.catalogItems.observe(this) { value ->
+ (binding.recyclerViewProducts.adapter as ProductAdapter).setItems(value)
+ }
+ viewModel.updatedItem.observe(this) { product ->
+ if (product != null) {
+ (binding.recyclerViewProducts.adapter as ProductAdapter).updateItem(product)
+ }
+ }
+ viewModel.recentlyViewedProducts.observe(this) { products ->
+ (binding.recyclerViewRecentlyViewedProducts.adapter as RecentlyViewedProductAdapter).setItems(
+ products,
+ )
+ }
+ viewModel.loadingState.observe(this) {
+ when (it.isLoading) {
+ true -> {
+ binding.shimmerFrameLayoutProducts.startShimmer()
+ binding.shimmerFrameLayoutProducts.visibility = View.VISIBLE
+ }
+
+ false -> {
+ binding.shimmerFrameLayoutProducts.stopShimmer()
+ binding.shimmerFrameLayoutProducts.visibility = View.GONE
+ }
+ }
+ }
+ binding.lifecycleOwner = this
+ }
+
+ private fun applyWindowInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
+ insets
+ }
+ }
+
+ private fun spanSizeByPosition(
+ position: Int,
+ itemCount: Int,
+ ): Int = if (position == itemCount - 1 && itemCount % 20 == 1) 2 else 1
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt
new file mode 100644
index 000000000..48496899e
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt
@@ -0,0 +1,15 @@
+package woowacourse.shopping.product.catalog
+
+import woowacourse.shopping.domain.LoadingState
+
+sealed class CatalogItem {
+ data class ProductItem(
+ val productItem: ProductUiModel,
+ ) : CatalogItem()
+
+ data object LoadMoreButtonItem : CatalogItem()
+
+ data class LoadingStateProductItem(
+ val loadingState: LoadingState,
+ ) : CatalogItem()
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt
new file mode 100644
index 000000000..a308cff65
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt
@@ -0,0 +1,170 @@
+package woowacourse.shopping.product.catalog
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import woowacourse.shopping.data.mapper.toUiModel
+import woowacourse.shopping.data.repository.CartProductRepository
+import woowacourse.shopping.data.repository.CatalogProductRepository
+import woowacourse.shopping.data.repository.RecentlyViewedProductRepository
+import woowacourse.shopping.domain.LoadingState
+import woowacourse.shopping.product.catalog.CatalogItem.ProductItem
+
+class CatalogViewModel(
+ private val catalogProductRepository: CatalogProductRepository,
+ private val cartProductRepository: CartProductRepository,
+ private val recentlyViewedProductRepository: RecentlyViewedProductRepository,
+) : ViewModel() {
+ private val _catalogItems =
+ MutableLiveData>(emptyList())
+ val catalogItems: LiveData> = _catalogItems
+
+ private val _page = MutableLiveData(INITIAL_PAGE)
+ val page: LiveData = _page
+
+ private val _updatedItem = MutableLiveData()
+ val updatedItem: LiveData = _updatedItem
+
+ private val _recentlyViewedProducts =
+ MutableLiveData>(emptyList())
+ val recentlyViewedProducts: LiveData> = _recentlyViewedProducts
+
+ private val _loadingState: MutableLiveData =
+ MutableLiveData(LoadingState.loaded())
+ val loadingState: LiveData get() = _loadingState
+
+ private val _cartItemCount = MutableLiveData(INITIAL_CART_ITEM_COUNT)
+ val cartItemCount: LiveData get() = _cartItemCount
+
+ init {
+ fetchTotalCount()
+ catalogProductRepository.getAllProductsSize { allProductsSize ->
+ loadCatalog(PAGE_SIZE, 20, allProductsSize)
+ }
+ }
+
+ fun fetchTotalCount() {
+ cartProductRepository.getTotalElements { count ->
+ _cartItemCount.postValue(count)
+ }
+ }
+
+ fun increaseQuantity(product: ProductUiModel) {
+ if (product.quantity == 0) {
+ cartProductRepository.insertCartProduct(product.copy(quantity = 1)) { product ->
+ _updatedItem.postValue(product)
+ }
+ } else {
+ cartProductRepository.updateProduct(product, product.quantity + 1) { result ->
+ if (result == true) {
+ _updatedItem.postValue(product.copy(quantity = product.quantity + 1))
+ }
+ }
+ }
+ fetchTotalCount()
+ }
+
+ fun decreaseQuantity(product: ProductUiModel) {
+ if (product.quantity == 1) {
+ cartProductRepository.deleteCartProduct(product) { result ->
+ if (result == true) {
+ _updatedItem.postValue(product.copy(quantity = 0))
+ }
+ }
+ } else {
+ cartProductRepository.updateProduct(product, product.quantity - 1) { result ->
+ if (result == true) {
+ _updatedItem.postValue(product.copy(quantity = product.quantity - 1))
+ }
+ }
+ }
+ fetchTotalCount()
+ }
+
+ fun loadNextCatalogProducts() {
+ increasePage()
+ val currentPage = page.value ?: 0
+
+ catalogProductRepository.getAllProductsSize { allProductSize ->
+ val startIndex = currentPage * PAGE_SIZE
+ val endIndex = minOf(startIndex + PAGE_SIZE, allProductSize)
+ loadCatalog(endIndex, 20, allProductSize)
+ }
+ }
+
+ fun loadCatalogUntilCurrentPage() {
+ _catalogItems.value = emptyList()
+ val currentPage = page.value ?: 0
+
+ catalogProductRepository.getAllProductsSize { allProductSize ->
+ val endIndex = minOf((currentPage + 1) * PAGE_SIZE, allProductSize)
+
+ loadCatalog(endIndex, 20, allProductSize)
+ }
+ }
+
+ fun loadCatalog(
+ endIndex: Int,
+ size: Int = 20,
+ allProductsSize: Int,
+ ) {
+ _loadingState.postValue(LoadingState.loading())
+
+ catalogProductRepository.getProductsByPage(
+ page.value ?: 0,
+ size,
+ ) { pagedProducts ->
+ cartProductRepository.getTotalElements { totalElements ->
+ cartProductRepository.getCartProducts(totalElements) { cartProducts ->
+ val cartProductMap: Map =
+ cartProducts.associateBy { it.id }
+
+ val mergedProducts =
+ pagedProducts.map { product ->
+ val cartProduct = cartProductMap[product.id]
+ if (cartProduct != null) {
+ product.copy(
+ quantity = cartProduct.quantity,
+ cartItemId = cartProduct.cartItemId,
+ )
+ } else {
+ product
+ }
+ }
+
+ val items = mergedProducts.map { ProductItem(it) }
+ val prevItems =
+ _catalogItems.value
+ .orEmpty()
+ .filterNot { it is CatalogItem.LoadMoreButtonItem }
+ val hasNextPage = endIndex < allProductsSize
+ val updatedItems =
+ if (hasNextPage) {
+ prevItems + items + CatalogItem.LoadMoreButtonItem
+ } else {
+ prevItems + items
+ }
+
+ _catalogItems.postValue(updatedItems)
+ _loadingState.postValue(LoadingState.loaded())
+ }
+ }
+ }
+ }
+
+ fun loadRecentlyViewedProducts() {
+ recentlyViewedProductRepository.getRecentlyViewedProducts { products ->
+ _recentlyViewedProducts.postValue(products.map { it.toUiModel() })
+ }
+ }
+
+ private fun increasePage() {
+ _page.value = _page.value?.plus(1)
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 20
+ private const val INITIAL_PAGE = 0
+ private const val INITIAL_CART_ITEM_COUNT = 0
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt
new file mode 100644
index 000000000..0a34feb9b
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt
@@ -0,0 +1,30 @@
+package woowacourse.shopping.product.catalog
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.data.database.ShoppingDatabase
+import woowacourse.shopping.data.repository.RecentlyViewedProductRepositoryImpl
+import woowacourse.shopping.data.repository.RemoteCartProductRepositoryImpl
+import woowacourse.shopping.data.repository.RemoteCatalogProductRepositoryImpl
+
+class CatalogViewModelFactory(
+ private val application: ShoppingApplication,
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(CatalogViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ return CatalogViewModel(
+ cartProductRepository =
+ RemoteCartProductRepositoryImpl(),
+ recentlyViewedProductRepository =
+ RecentlyViewedProductRepositoryImpl(
+ ShoppingDatabase.getInstance(application).recentlyViewedProductDao(),
+ RemoteCatalogProductRepositoryImpl(),
+ ),
+ catalogProductRepository = RemoteCatalogProductRepositoryImpl(),
+ ) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt
new file mode 100644
index 000000000..de5a2cfc5
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt
@@ -0,0 +1,22 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.databinding.LoadMoreButtonItemBinding
+
+class LoadButtonViewHolder(
+ binding: LoadMoreButtonItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ companion object {
+ fun from(
+ parent: ViewGroup,
+ productActionListener: ProductActionListener,
+ ): LoadButtonViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = LoadMoreButtonItemBinding.inflate(inflater, parent, false)
+ binding.productActionListener = productActionListener
+ return LoadButtonViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt
new file mode 100644
index 000000000..fe6c1104d
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt
@@ -0,0 +1,18 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.databinding.ShimmerProductItemBinding
+
+class LoadingStateProductViewHolder(
+ val binding: ShimmerProductItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ companion object {
+ fun from(parent: ViewGroup): LoadingStateProductViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = ShimmerProductItemBinding.inflate(inflater, parent, false)
+ return LoadingStateProductViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt
new file mode 100644
index 000000000..1d7cae050
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt
@@ -0,0 +1,9 @@
+package woowacourse.shopping.product.catalog
+
+interface ProductActionListener {
+ fun onProductClick(product: ProductUiModel)
+
+ fun onLoadButtonClick() = Unit
+
+ fun onQuantityAddClick(product: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt
new file mode 100644
index 000000000..27d2de377
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt
@@ -0,0 +1,64 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+
+class ProductAdapter(
+ products: List,
+ private val productActionListener: ProductActionListener,
+ private val quantityControlListener: QuantityControlListener,
+) : RecyclerView.Adapter() {
+ private val products: MutableList = products.toMutableList()
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): RecyclerView.ViewHolder =
+ when (viewType) {
+ VIEW_TYPE_PRODUCT ->
+ ProductViewHolder.from(parent, productActionListener, quantityControlListener)
+
+ VIEW_TYPE_LOAD_MORE -> LoadButtonViewHolder.from(parent, productActionListener)
+
+ else -> LoadingStateProductViewHolder.from(parent)
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ ) {
+ when (holder) {
+ is ProductViewHolder -> {
+ holder.bind((products[position] as CatalogItem.ProductItem).productItem)
+ }
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int =
+ when (products[position]) {
+ CatalogItem.LoadMoreButtonItem -> VIEW_TYPE_LOAD_MORE
+ is CatalogItem.ProductItem -> VIEW_TYPE_PRODUCT
+ is CatalogItem.LoadingStateProductItem -> VIEW_TYPE_LOADING_PRODUCT
+ }
+
+ fun setItems(items: List) {
+ products.clear()
+ products.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ fun updateItem(product: ProductUiModel) {
+ val index: Int =
+ products.indexOfFirst { (it as CatalogItem.ProductItem).productItem.id == product.id }
+ products[index] = CatalogItem.ProductItem(product)
+ notifyItemChanged(index)
+ }
+
+ override fun getItemCount(): Int = products.size
+
+ companion object {
+ private const val VIEW_TYPE_PRODUCT = 1
+ private const val VIEW_TYPE_LOAD_MORE = 2
+ private const val VIEW_TYPE_LOADING_PRODUCT = 3
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt
new file mode 100644
index 000000000..b19c0fdb0
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt
@@ -0,0 +1,16 @@
+package woowacourse.shopping.product.catalog
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class ProductUiModel(
+ val id: Int,
+ val imageUrl: String,
+ val name: String,
+ val price: Int,
+ val quantity: Int = 0,
+ val cartItemId: Int? = null,
+ val isChecked: Boolean = true,
+ val category: String? = null,
+) : Parcelable
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt
new file mode 100644
index 000000000..11a617736
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt
@@ -0,0 +1,41 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.databinding.ProductItemBinding
+
+class ProductViewHolder(
+ private val binding: ProductItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(product: ProductUiModel) {
+ val quantityControlBar = binding.layoutQuantityControlBar.linearLayoutQuantityControlBar
+ val fabBtn = binding.layoutQuantityControlBar.fabQuantityAdd
+
+ binding.product = product
+ binding.layoutQuantityControlBar.product = product
+
+ if (product.quantity == 0) {
+ quantityControlBar.visibility = View.INVISIBLE
+ fabBtn.visibility = View.VISIBLE
+ } else {
+ quantityControlBar.visibility = View.VISIBLE
+ fabBtn.visibility = View.INVISIBLE
+ }
+ }
+
+ companion object {
+ fun from(
+ parent: ViewGroup,
+ productActionListener: ProductActionListener,
+ quantityControlListener: QuantityControlListener,
+ ): ProductViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = ProductItemBinding.inflate(inflater, parent, false)
+ binding.productActionListener = productActionListener
+ binding.layoutQuantityControlBar.quantityControlListener = quantityControlListener
+ return ProductViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt
new file mode 100644
index 000000000..14c136b9f
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt
@@ -0,0 +1,8 @@
+package woowacourse.shopping.product.catalog
+
+fun interface QuantityControlListener {
+ fun onClick(
+ event: Int,
+ product: ProductUiModel,
+ )
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt
new file mode 100644
index 000000000..f305ba3ce
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt
@@ -0,0 +1,31 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+
+class RecentlyViewedProductAdapter(
+ products: List,
+ private val recentlyViewedProductClickListener: RecentlyViewedProductClickListener,
+) : RecyclerView.Adapter() {
+ private val products: MutableList = products.toMutableList()
+
+ fun setItems(products: List) {
+ this.products.clear()
+ this.products.addAll(products)
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): RecentlyViewedProductViewHolder = RecentlyViewedProductViewHolder.from(parent, recentlyViewedProductClickListener)
+
+ override fun onBindViewHolder(
+ holder: RecentlyViewedProductViewHolder,
+ position: Int,
+ ) {
+ holder.bind(products[position])
+ }
+
+ override fun getItemCount(): Int = products.size
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt
new file mode 100644
index 000000000..5531a8d35
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt
@@ -0,0 +1,5 @@
+package woowacourse.shopping.product.catalog
+
+fun interface RecentlyViewedProductClickListener {
+ fun onClick(productUiModel: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt
new file mode 100644
index 000000000..b69373eb5
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt
@@ -0,0 +1,26 @@
+package woowacourse.shopping.product.catalog
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import woowacourse.shopping.databinding.RecentlyViewedProductItemBinding
+
+class RecentlyViewedProductViewHolder(
+ private val binding: RecentlyViewedProductItemBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(product: ProductUiModel) {
+ binding.product = product
+ }
+
+ companion object {
+ fun from(
+ parent: ViewGroup,
+ recentlyViewedProductClickListener: RecentlyViewedProductClickListener,
+ ): RecentlyViewedProductViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = RecentlyViewedProductItemBinding.inflate(inflater, parent, false)
+ binding.clickListener = recentlyViewedProductClickListener
+ return RecentlyViewedProductViewHolder(binding)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt b/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt
new file mode 100644
index 000000000..f326168a9
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt
@@ -0,0 +1,7 @@
+package woowacourse.shopping.product.detail
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+fun interface AddToCartClickListener {
+ fun onClick(product: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt
new file mode 100644
index 000000000..546bf1280
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt
@@ -0,0 +1,144 @@
+package woowacourse.shopping.product.detail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.R
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.databinding.ActivityDetailBinding
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.product.catalog.QuantityControlListener
+import woowacourse.shopping.util.intentParcelableExtra
+
+class DetailActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityDetailBinding
+ private lateinit var viewModel: DetailViewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ binding = DataBindingUtil.setContentView(this, R.layout.activity_detail)
+
+ applyWindowInsets()
+ setSupportActionBar()
+ setAddToCartClickListener()
+
+ val product: ProductUiModel? =
+ intent.intentParcelableExtra(KEY_PRODUCT_DETAIL, ProductUiModel::class.java)
+ product?.let { setViewModel(product) }
+ observeProduct()
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+ binding.layoutQuantityControlBar.quantityControlListener =
+ QuantityControlListener { event, _ ->
+ if (event == 1) {
+ viewModel.increaseQuantity()
+ } else {
+ viewModel.decreaseQuantity()
+ }
+ }
+ binding.layoutLatestViewedProduct.latestViewedProductClickListener =
+ LatestViewedProductClickListener { product ->
+ val intent = DetailActivity.newIntent(this, product)
+ intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
+ startActivity(intent)
+ }
+ viewModel.setLatestViewedProduct()
+ }
+
+ private fun setViewModel(product: ProductUiModel) {
+ viewModel =
+ ViewModelProvider(
+ this,
+ DetailViewModelFactory(product, application as ShoppingApplication),
+ )[DetailViewModel::class.java]
+ }
+
+ private fun observeProduct() {
+ binding.vm = viewModel
+ binding.lifecycleOwner = this
+
+ viewModel.product.observe(this) {
+ viewModel.setQuantity()
+ viewModel.setPriceSum()
+ viewModel.addToRecentlyViewedProduct()
+ binding.layoutQuantityControlBar.product = it
+ }
+
+ viewModel.quantity.observe(this) {
+ viewModel.setPriceSum()
+ }
+
+ viewModel.latestViewedProduct.observe(this) { product ->
+ binding.layoutLatestViewedProduct.product = product
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.detail_back_menu_item, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean =
+ when (item.itemId) {
+ R.id.menu_detail_back -> {
+ finish()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun setAddToCartClickListener() {
+ binding.addToCartClickListener =
+ AddToCartClickListener { product ->
+ showToastMessage()
+ viewModel.addToCart()
+ finish()
+ }
+ }
+
+ private fun setSupportActionBar() {
+ supportActionBar?.setDisplayHomeAsUpEnabled(false)
+ supportActionBar?.setDisplayShowHomeEnabled(false)
+ supportActionBar?.title = ""
+ }
+
+ private fun showToastMessage() {
+ Toast
+ .makeText(this, getString(R.string.text_add_to_cart_success), Toast.LENGTH_SHORT)
+ .show()
+ }
+
+ private fun applyWindowInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
+ insets
+ }
+ }
+
+ private fun productFromIntent(): ProductUiModel? = intent.intentParcelableExtra(KEY_PRODUCT_DETAIL, ProductUiModel::class.java)
+
+ companion object {
+ private const val KEY_PRODUCT_DETAIL = "productDetail"
+
+ fun newIntent(
+ context: Context,
+ product: ProductUiModel,
+ ): Intent =
+ Intent(context, DetailActivity::class.java).apply {
+ putExtra(KEY_PRODUCT_DETAIL, product)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt
new file mode 100644
index 000000000..2e5102641
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt
@@ -0,0 +1,70 @@
+package woowacourse.shopping.product.detail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import woowacourse.shopping.data.repository.CartProductRepository
+import woowacourse.shopping.data.repository.CatalogProductRepository
+import woowacourse.shopping.data.repository.RecentlyViewedProductRepository
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class DetailViewModel(
+ product: ProductUiModel,
+ private val cartProductRepository: CartProductRepository,
+ private val recentlyViewedProductRepository: RecentlyViewedProductRepository,
+ private val catalogProductRepository: CatalogProductRepository,
+) : ViewModel() {
+ private val _product = MutableLiveData(product)
+ val product: LiveData = _product
+
+ private val _quantity = MutableLiveData(1)
+ val quantity: LiveData = _quantity
+
+ private val _price = MutableLiveData(0)
+ val price: LiveData = _price
+
+ private val _latestViewedProduct = MutableLiveData()
+ val latestViewedProduct: LiveData = _latestViewedProduct
+
+ fun increaseQuantity() {
+ _quantity.value = _quantity.value?.plus(1)
+ setPriceSum()
+ }
+
+ fun decreaseQuantity() {
+ if ((_quantity.value ?: 0) <= 0) return
+ _quantity.value = _quantity.value?.minus(1)
+ setPriceSum()
+ }
+
+ fun setQuantity() {
+// _quantity.value = productData.quantity
+ }
+
+ fun setPriceSum() {
+ _price.value = (product.value?.price ?: 0) * (quantity.value ?: 0)
+ }
+
+ fun addToCart() {
+ val addedProduct = product.value?.copy(quantity = quantity.value ?: 0)
+
+ if (addedProduct?.cartItemId != null) {
+ cartProductRepository.updateProduct(addedProduct, addedProduct.quantity) {}
+ } else {
+ addedProduct?.let {
+ cartProductRepository.insertCartProduct(it) {}
+ }
+ }
+ }
+
+ fun addToRecentlyViewedProduct() {
+ val uid = product.value?.id ?: return
+ recentlyViewedProductRepository.insertRecentlyViewedProductUid(uid)
+ }
+
+ fun setLatestViewedProduct() {
+ recentlyViewedProductRepository.getLatestViewedProduct { product ->
+ _latestViewedProduct.postValue(product)
+ }
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt
new file mode 100644
index 000000000..a7aeff2ad
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt
@@ -0,0 +1,32 @@
+package woowacourse.shopping.product.detail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import woowacourse.shopping.ShoppingApplication
+import woowacourse.shopping.data.database.ShoppingDatabase
+import woowacourse.shopping.data.repository.RecentlyViewedProductRepositoryImpl
+import woowacourse.shopping.data.repository.RemoteCartProductRepositoryImpl
+import woowacourse.shopping.data.repository.RemoteCatalogProductRepositoryImpl
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class DetailViewModelFactory(
+ private val product: ProductUiModel,
+ private val application: ShoppingApplication,
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(DetailViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ return DetailViewModel(
+ product = product,
+ cartProductRepository = RemoteCartProductRepositoryImpl(),
+ recentlyViewedProductRepository =
+ RecentlyViewedProductRepositoryImpl(
+ ShoppingDatabase.getInstance(application).recentlyViewedProductDao(),
+ RemoteCatalogProductRepositoryImpl(),
+ ),
+ catalogProductRepository = RemoteCatalogProductRepositoryImpl(),
+ ) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt b/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt
new file mode 100644
index 000000000..f5b9e7f61
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt
@@ -0,0 +1,7 @@
+package woowacourse.shopping.product.detail
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+fun interface LatestViewedProductClickListener {
+ fun onClick(product: ProductUiModel)
+}
diff --git a/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt b/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt
new file mode 100644
index 000000000..d89a8b42a
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt
@@ -0,0 +1,21 @@
+package woowacourse.shopping.util
+
+import android.widget.ImageView
+import androidx.databinding.BindingAdapter
+import com.bumptech.glide.Glide
+import woowacourse.shopping.R
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+object BindingAdapter {
+ @JvmStatic
+ @BindingAdapter("imageUrl")
+ fun ImageView.loadImage(productUiModel: ProductUiModel) {
+ Glide
+ .with(this)
+ .load(productUiModel.imageUrl)
+ .placeholder(R.drawable.iced_americano)
+ .fallback(R.drawable.iced_americano)
+ .error(R.drawable.iced_americano)
+ .into(this)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/util/Event.kt b/app/src/main/java/woowacourse/shopping/util/Event.kt
new file mode 100644
index 000000000..01991e997
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/util/Event.kt
@@ -0,0 +1,26 @@
+package woowacourse.shopping.util
+
+/**
+ * Used as a wrapper for data that is exposed via a LiveData that represents an event.
+ */
+open class Event(private val content: T) {
+ var hasBeenHandled = false
+ private set // Allow external read but not write
+
+ /**
+ * Returns the content and prevents its use again.
+ */
+ fun getContentIfNotHandled(): T? {
+ return if (hasBeenHandled) {
+ null
+ } else {
+ hasBeenHandled = true
+ content
+ }
+ }
+
+ /**
+ * Returns the content, even if it's already been handled.
+ */
+ fun peekContent(): T = content
+}
diff --git a/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt b/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt
new file mode 100644
index 000000000..cd8908008
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt
@@ -0,0 +1,16 @@
+package woowacourse.shopping.util
+
+import android.content.Intent
+import android.os.Build
+import android.os.Parcelable
+
+@Suppress("DEPRECATION")
+fun Intent.intentParcelableExtra(
+ key: String,
+ clazz: Class,
+): T? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(key, clazz)
+ } else {
+ getParcelableExtra(key) as? T
+ }
diff --git a/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt b/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt
new file mode 100644
index 000000000..9d2aa1523
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt
@@ -0,0 +1,16 @@
+
+package woowacourse.shopping.util
+
+class MutableSingleLiveData : SingleLiveData {
+ constructor() : super()
+
+ constructor(value: T) : super(value)
+
+ public override fun postValue(value: T) {
+ super.postValue(value)
+ }
+
+ public override fun setValue(value: T) {
+ super.setValue(value)
+ }
+}
diff --git a/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt b/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt
new file mode 100644
index 000000000..fa7595683
--- /dev/null
+++ b/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt
@@ -0,0 +1,38 @@
+package woowacourse.shopping.util
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MutableLiveData
+
+abstract class SingleLiveData {
+ private val liveData = MutableLiveData>()
+
+ protected constructor()
+
+ protected constructor(value: T) {
+ liveData.value = Event(value)
+ }
+
+ protected open fun setValue(value: T) {
+ liveData.value = Event(value)
+ }
+
+ protected open fun postValue(value: T) {
+ liveData.postValue(Event(value))
+ }
+
+ fun getValue() = liveData.value?.peekContent()
+
+ fun observe(
+ owner: LifecycleOwner,
+ onResult: (T) -> Unit,
+ ) {
+ liveData.observe(owner) { it.getContentIfNotHandled()?.let(onResult) }
+ }
+
+ fun observePeek(
+ owner: LifecycleOwner,
+ onResult: (T) -> Unit,
+ ) {
+ liveData.observe(owner) { onResult(it.peekContent()) }
+ }
+}
diff --git a/app/src/main/res/drawable/add_to_cart_button.xml b/app/src/main/res/drawable/add_to_cart_button.xml
new file mode 100644
index 000000000..7bd9207b5
--- /dev/null
+++ b/app/src/main/res/drawable/add_to_cart_button.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml
new file mode 100644
index 000000000..0eb7296a0
--- /dev/null
+++ b/app/src/main/res/drawable/badge_background.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/cart_item_background.xml b/app/src/main/res/drawable/cart_item_background.xml
new file mode 100644
index 000000000..773c7c86d
--- /dev/null
+++ b/app/src/main/res/drawable/cart_item_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/cart_next_button_active_background.xml b/app/src/main/res/drawable/cart_next_button_active_background.xml
new file mode 100644
index 000000000..ea9c722cc
--- /dev/null
+++ b/app/src/main/res/drawable/cart_next_button_active_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/cart_next_button_background.xml b/app/src/main/res/drawable/cart_next_button_background.xml
new file mode 100644
index 000000000..78755a34d
--- /dev/null
+++ b/app/src/main/res/drawable/cart_next_button_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/cart_prev_button_active_background.xml b/app/src/main/res/drawable/cart_prev_button_active_background.xml
new file mode 100644
index 000000000..e9ded913a
--- /dev/null
+++ b/app/src/main/res/drawable/cart_prev_button_active_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/cart_prev_button_background.xml b/app/src/main/res/drawable/cart_prev_button_background.xml
new file mode 100644
index 000000000..b6a7b8757
--- /dev/null
+++ b/app/src/main/res/drawable/cart_prev_button_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/iced_americano.png b/app/src/main/res/drawable/iced_americano.png
new file mode 100644
index 000000000..f7fd5fbe8
Binary files /dev/null and b/app/src/main/res/drawable/iced_americano.png differ
diff --git a/app/src/main/res/drawable/icon_add.xml b/app/src/main/res/drawable/icon_add.xml
new file mode 100644
index 000000000..1f81f6d22
--- /dev/null
+++ b/app/src/main/res/drawable/icon_add.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/icon_back.png b/app/src/main/res/drawable/icon_back.png
new file mode 100644
index 000000000..3bda3b7e1
Binary files /dev/null and b/app/src/main/res/drawable/icon_back.png differ
diff --git a/app/src/main/res/drawable/icon_cart.png b/app/src/main/res/drawable/icon_cart.png
new file mode 100644
index 000000000..60435f754
Binary files /dev/null and b/app/src/main/res/drawable/icon_cart.png differ
diff --git a/app/src/main/res/drawable/icon_delete.png b/app/src/main/res/drawable/icon_delete.png
new file mode 100644
index 000000000..88d4126fb
Binary files /dev/null and b/app/src/main/res/drawable/icon_delete.png differ
diff --git a/app/src/main/res/drawable/pagination_next_selector.xml b/app/src/main/res/drawable/pagination_next_selector.xml
new file mode 100644
index 000000000..205b30fce
--- /dev/null
+++ b/app/src/main/res/drawable/pagination_next_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/pagination_prev_selector.xml b/app/src/main/res/drawable/pagination_prev_selector.xml
new file mode 100644
index 000000000..8aac6cfa8
--- /dev/null
+++ b/app/src/main/res/drawable/pagination_prev_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/quantity_control_background.xml b/app/src/main/res/drawable/quantity_control_background.xml
new file mode 100644
index 000000000..c22826bc4
--- /dev/null
+++ b/app/src/main/res/drawable/quantity_control_background.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_cart.xml b/app/src/main/res/layout/activity_cart.xml
new file mode 100644
index 000000000..4391db228
--- /dev/null
+++ b/app/src/main/res/layout/activity_cart.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_catalog.xml b/app/src/main/res/layout/activity_catalog.xml
new file mode 100644
index 000000000..e06a28c9c
--- /dev/null
+++ b/app/src/main/res/layout/activity_catalog.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml
new file mode 100644
index 000000000..2944e969d
--- /dev/null
+++ b/app/src/main/res/layout/activity_detail.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 24d17df2c..000000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/cart_item.xml b/app/src/main/res/layout/cart_item.xml
new file mode 100644
index 000000000..dcb07c708
--- /dev/null
+++ b/app/src/main/res/layout/cart_item.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_cart_recommendation.xml b/app/src/main/res/layout/fragment_cart_recommendation.xml
new file mode 100644
index 000000000..5877248f7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_cart_recommendation.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_cart_selection.xml b/app/src/main/res/layout/fragment_cart_selection.xml
new file mode 100644
index 000000000..45ee8ff18
--- /dev/null
+++ b/app/src/main/res/layout/fragment_cart_selection.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/latest_viewed_product.xml b/app/src/main/res/layout/latest_viewed_product.xml
new file mode 100644
index 000000000..6a18fde95
--- /dev/null
+++ b/app/src/main/res/layout/latest_viewed_product.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/load_more_button_item.xml b/app/src/main/res/layout/load_more_button_item.xml
new file mode 100644
index 000000000..8e3de411b
--- /dev/null
+++ b/app/src/main/res/layout/load_more_button_item.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/menu_cart_layout.xml b/app/src/main/res/layout/menu_cart_layout.xml
new file mode 100644
index 000000000..3ddd18dbb
--- /dev/null
+++ b/app/src/main/res/layout/menu_cart_layout.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/pagination_button_item.xml b/app/src/main/res/layout/pagination_button_item.xml
new file mode 100644
index 000000000..5ac8fae99
--- /dev/null
+++ b/app/src/main/res/layout/pagination_button_item.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/product_item.xml b/app/src/main/res/layout/product_item.xml
new file mode 100644
index 000000000..a2ea7b293
--- /dev/null
+++ b/app/src/main/res/layout/product_item.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/quantity_control_bar.xml b/app/src/main/res/layout/quantity_control_bar.xml
new file mode 100644
index 000000000..3815b8019
--- /dev/null
+++ b/app/src/main/res/layout/quantity_control_bar.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/recently_viewed_product_item.xml b/app/src/main/res/layout/recently_viewed_product_item.xml
new file mode 100644
index 000000000..740003a94
--- /dev/null
+++ b/app/src/main/res/layout/recently_viewed_product_item.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/shimmer_cart_product_item.xml b/app/src/main/res/layout/shimmer_cart_product_item.xml
new file mode 100644
index 000000000..1b4348b20
--- /dev/null
+++ b/app/src/main/res/layout/shimmer_cart_product_item.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/shimmer_product_item.xml b/app/src/main/res/layout/shimmer_product_item.xml
new file mode 100644
index 000000000..50eaa2e91
--- /dev/null
+++ b/app/src/main/res/layout/shimmer_product_item.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/cart_menu_item.xml b/app/src/main/res/menu/cart_menu_item.xml
new file mode 100644
index 000000000..947987d06
--- /dev/null
+++ b/app/src/main/res/menu/cart_menu_item.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/main/res/menu/detail_back_menu_item.xml b/app/src/main/res/menu/detail_back_menu_item.xml
new file mode 100644
index 000000000..b77ed78e1
--- /dev/null
+++ b/app/src/main/res/menu/detail_back_menu_item.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index ca1931bca..660159fde 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,9 @@
#FF018786
#FF000000
#FFFFFFFF
+ #04C09E
+ #AAAAAA
+ #555555
+ #E2E2E2
+ #F3F3F3
diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml
new file mode 100644
index 000000000..19e690491
--- /dev/null
+++ b/app/src/main/res/values/dimen.xml
@@ -0,0 +1,12 @@
+
+
+ 12sp
+ 16sp
+ 18sp
+ 20sp
+ 24sp
+ 36sp
+ 12dp
+ 20dp
+ 0dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6d300ab6e..95cc5d353 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,22 @@
Shopping
+ %,d원
+ product_detail_image
+ product_image
+ 가격
+ 장바구니 담기
+ Cart
+ 장바구니에 상품이 추가되었습니다.
+ Back
+ Cart
+
+ ]]>
+ 최근 본 상품
+ 마지막으로 본 상품
+ 주문하기(%d)
+
+ Hello blank fragment
+ 이런 상품은 어떠세요?
+ * 최근 본 상품 기반으로 좋아하실 것 같은 상품들을 추천해드려요.
+ 전체
diff --git a/app/src/main/res/values/style.xml b/app/src/main/res/values/style.xml
new file mode 100644
index 000000000..4a512dd84
--- /dev/null
+++ b/app/src/main/res/values/style.xml
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 000000000..44da09a8c
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+ localhost
+ techcourse-lv2-alb-974870821.ap-northeast-2.elb.amazonaws.com
+
+
diff --git a/app/src/test/java/woowacourse/shopping/cart/CartViewModelTest.kt b/app/src/test/java/woowacourse/shopping/cart/CartViewModelTest.kt
new file mode 100644
index 000000000..fe6eaa0c4
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/cart/CartViewModelTest.kt
@@ -0,0 +1,118 @@
+package woowacourse.shopping.cart
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Rule
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import woowacourse.shopping.data.mapper.toEntity
+import woowacourse.shopping.data.repository.FakeCartProductRepositoryImpl
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.util.InstantTaskExecutorExtension
+import woowacourse.shopping.util.getOrAwaitValue
+
+@ExtendWith(InstantTaskExecutorExtension::class)
+class CartViewModelTest {
+ @get:Rule
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ private lateinit var viewModel: CartViewModel
+ private lateinit var fakeCartRepository: FakeCartProductRepositoryImpl
+
+ private val dummyProducts =
+ (1..12).map {
+ ProductUiModel(
+ id = it,
+ name = "상품 $it",
+ imageUrl = "https://example.com/$it.jpg",
+ price = 1000 * it,
+ quantity = it,
+ )
+ }
+
+ @BeforeEach
+ fun setUp() {
+ fakeCartRepository =
+ FakeCartProductRepositoryImpl().apply {
+ dummyProducts.forEach { insertCartProduct(it.toEntity()) }
+ }
+ viewModel = CartViewModel(fakeCartRepository)
+ }
+
+ @Test
+ fun `초기 로딩 시 첫 페이지의 상품 5개만 로딩된다`() {
+ val loaded = viewModel.cartProducts.getOrAwaitValue()
+ Assertions.assertNotNull(loaded)
+ assertThat(loaded.size).isEqualTo(5)
+ assertThat(loaded.first().name).isEqualTo("상품 1")
+ }
+
+ @Test
+ fun `다음 페이지 버튼 클릭 시 다음 상품 페이지 로딩`() {
+ viewModel.cartProducts.getOrAwaitValue()
+ viewModel.onPaginationButtonClick(2) // NEXT_BUTTON = 2
+ val loaded = viewModel.cartProducts.getOrAwaitValue()
+ Assertions.assertNotNull(loaded)
+ assertThat(loaded.size).isEqualTo(5)
+ assertThat(loaded.first().name).isEqualTo("상품 6")
+ }
+
+ @Test
+ fun `이전 페이지 버튼 클릭 시 이전 상품 페이지 로딩`() {
+ viewModel.cartProducts.getOrAwaitValue()
+ viewModel.onPaginationButtonClick(2) // NEXT_BUTTON
+ viewModel.cartProducts.getOrAwaitValue()
+ viewModel.onPaginationButtonClick(1) // PREV_BUTTON
+ val loaded = viewModel.cartProducts.getOrAwaitValue()
+ assertThat(loaded.first().name).isEqualTo("상품 1")
+ }
+
+ @Test
+ fun `상품 수량 증가 시 updatedItem에 반영된다`() {
+ val product = dummyProducts[0]
+ viewModel.updateQuantity(1, product) // INCREASE_BUTTON
+ val updated = viewModel.updatedItem.getOrAwaitValue()
+ Assertions.assertNotNull(updated)
+ assertThat(product.id).isEqualTo(updated.id)
+ assertThat(product.quantity + 1).isEqualTo(updated.quantity)
+ }
+
+ @Test
+ fun `상품 수량 감소 시 updatedItem에 반영된다`() {
+ val product = dummyProducts[1]
+ viewModel.updateQuantity(0, product) // DECREASE_BUTTON
+ val updated = viewModel.updatedItem.getOrAwaitValue()
+ Assertions.assertNotNull(updated)
+ assertThat(product.id).isEqualTo(updated?.id)
+ assertThat(product.quantity - 1).isEqualTo(updated.quantity)
+ }
+
+ @Test
+ fun `상품 삭제 시 해당 상품이 목록에서 제거된다`() {
+ val toDelete = viewModel.cartProducts.getOrAwaitValue().first()
+ viewModel.deleteCartProduct(CartItem.ProductItem(toDelete))
+ val updated = viewModel.cartProducts.getOrAwaitValue()
+ assertThat(updated.any { it.id == toDelete.id }).isEqualTo(false)
+ }
+
+ @Test
+ fun `페이지 끝에서 상품 삭제 시 페이지가 감소된다`() {
+ // 페이지 끝으로 이동
+ viewModel.onPaginationButtonClick(2)
+ viewModel.onPaginationButtonClick(2)
+ val lastPage = viewModel.page.getOrAwaitValue()
+ val toDelete = viewModel.cartProducts.getOrAwaitValue().first()
+
+ viewModel.deleteCartProduct(CartItem.ProductItem(toDelete))
+ val newPage = viewModel.page.getOrAwaitValue()
+ assertThat(newPage <= lastPage).isEqualTo(true)
+ }
+
+ @Test
+ fun `초기 상태에서 이전 버튼은 비활성화, 다음 버튼은 활성화`() {
+ assertThat(viewModel.isPrevButtonEnabled.getOrAwaitValue()).isEqualTo(false)
+ assertThat(viewModel.isNextButtonEnabled.getOrAwaitValue()).isEqualTo(true)
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/data/repository/FakeCartProductRepositoryImpl.kt b/app/src/test/java/woowacourse/shopping/data/repository/FakeCartProductRepositoryImpl.kt
new file mode 100644
index 000000000..c27c636bb
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/data/repository/FakeCartProductRepositoryImpl.kt
@@ -0,0 +1,55 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.entity.CartProductEntity
+
+class FakeCartProductRepositoryImpl : CartProductRepository {
+ val cartProducts: MutableList = mutableListOf()
+
+ override fun insertCartProduct(cartProduct: CartProductEntity) {
+ cartProducts.add(cartProduct)
+ }
+
+ override fun deleteCartProduct(cartProduct: CartProductEntity) {
+ cartProducts.remove(cartProduct)
+ }
+
+ override fun getCartProductsInRange(
+ startIndex: Int,
+ endIndex: Int,
+ callback: (List) -> Unit,
+ ) {
+ val safeEndIndex = minOf(endIndex, cartProducts.size)
+ val safeStartIndex = minOf(startIndex, safeEndIndex)
+
+ callback(cartProducts.subList(safeStartIndex, safeEndIndex))
+ }
+
+ override fun updateProduct(
+ cartProduct: CartProductEntity,
+ diff: Int,
+ callback: (CartProductEntity?) -> Unit,
+ ) {
+ val index: Int = cartProducts.indexOf(cartProduct)
+ val product: CartProductEntity = cartProducts[index]
+ cartProducts[index] = product.copy(quantity = product.quantity + diff)
+ callback(cartProducts[index])
+ }
+
+ override fun getProductQuantity(
+ id: Int,
+ callback: (Int?) -> Unit,
+ ) {
+ val index: Int = cartProducts.indexOfFirst { it.uid == id }
+ val quantity: Int = cartProducts[index].quantity
+ callback(quantity)
+ }
+
+ override fun getAllProductsSize(callback: (Int) -> Unit) {
+ callback(cartProducts.size)
+ }
+
+ override fun getCartItemSize(callback: (Int) -> Unit) {
+ val cartItemSize = cartProducts.sumOf { it.quantity }
+ callback(cartItemSize)
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/data/repository/FakeCatalogProductRepositoryImpl.kt b/app/src/test/java/woowacourse/shopping/data/repository/FakeCatalogProductRepositoryImpl.kt
new file mode 100644
index 000000000..a4842e4a0
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/data/repository/FakeCatalogProductRepositoryImpl.kt
@@ -0,0 +1,40 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class FakeCatalogProductRepositoryImpl(
+ size: Int,
+) : CatalogProductRepository {
+ override fun getAllProductsSize(callback: (Int) -> Unit) {
+ callback(dummyProducts.size)
+ }
+
+ override fun getProductsInRange(
+ startIndex: Int,
+ endIndex: Int,
+ callback: (List) -> Unit,
+ ) {
+ callback(dummyProducts.subList(startIndex, endIndex))
+ }
+
+ override fun getCartProductsByUids(
+ uids: List,
+ callback: (List) -> Unit,
+ ) {
+ callback(
+ uids.mapNotNull { uid ->
+ dummyProducts.find { product -> product.id == uid }
+ },
+ )
+ }
+
+ val dummyProducts =
+ MutableList(size) {
+ ProductUiModel(
+ id = size,
+ name = "아이스 카페 아메리카노",
+ imageUrl = "https://image.istarbucks.co.kr/upload/store/skuimg/2021/04/[110563]_20210426095937947.jpg",
+ price = 10000,
+ )
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/data/repository/FakeRecentlyViewedProductRepositoryImpl.kt b/app/src/test/java/woowacourse/shopping/data/repository/FakeRecentlyViewedProductRepositoryImpl.kt
new file mode 100644
index 000000000..df7ae0728
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/data/repository/FakeRecentlyViewedProductRepositoryImpl.kt
@@ -0,0 +1,29 @@
+package woowacourse.shopping.data.repository
+
+import woowacourse.shopping.data.entity.CartProductEntity
+import woowacourse.shopping.data.mapper.toEntity
+import woowacourse.shopping.product.catalog.ProductUiModel
+
+class FakeRecentlyViewedProductRepositoryImpl(
+ val catalogProductRepository: CatalogProductRepository,
+) : RecentlyViewedProductRepository {
+ val productUids: LinkedHashSet = linkedSetOf()
+
+ override fun insertRecentlyViewedProductUid(uid: Int) {
+ productUids.add(uid)
+ }
+
+ override fun getRecentlyViewedProducts(callback: (List) -> Unit) {
+ catalogProductRepository.getCartProductsByUids(productUids.toList()) { products ->
+ callback(products.map { it.toEntity() })
+ }
+ }
+
+ override fun getLatestViewedProduct(callback: (ProductUiModel) -> Unit) {
+ val lastIndex = listOf(productUids.last)
+ catalogProductRepository.getCartProductsByUids(lastIndex) { products ->
+ val first = products.first()
+ callback(first)
+ }
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/product/catalog/CatalogViewModelTest.kt b/app/src/test/java/woowacourse/shopping/product/catalog/CatalogViewModelTest.kt
new file mode 100644
index 000000000..0c2ddd207
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/product/catalog/CatalogViewModelTest.kt
@@ -0,0 +1,73 @@
+package woowacourse.shopping.product.catalog
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Rule
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import woowacourse.shopping.data.repository.FakeCartProductRepositoryImpl
+import woowacourse.shopping.data.repository.FakeCatalogProductRepositoryImpl
+import woowacourse.shopping.data.repository.FakeRecentlyViewedProductRepositoryImpl
+import woowacourse.shopping.util.InstantTaskExecutorExtension
+
+@ExtendWith(InstantTaskExecutorExtension::class)
+class CatalogViewModelTest {
+ @get:Rule
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ private lateinit var viewModel: CatalogViewModel
+
+ @Test
+ fun `초기 상태 테스트`() {
+ val catalogProductRepositoryImpl = FakeCatalogProductRepositoryImpl(size = 20)
+ viewModel =
+ CatalogViewModel(
+ catalogProductRepository = catalogProductRepositoryImpl,
+ cartProductRepository = FakeCartProductRepositoryImpl(),
+ recentlyViewedProductRepository =
+ FakeRecentlyViewedProductRepositoryImpl(catalogProductRepositoryImpl),
+ )
+
+ assertThat(0).isEqualTo(viewModel.page.value)
+
+ val catalogProducts: List = viewModel.catalogItems.value ?: emptyList()
+
+ assertThat(catalogProducts.size).isEqualTo(20)
+ }
+
+ @Test
+ fun `더보기 버튼을 눌렀을 때 페이지가 증가되고 상품이 로드된다`() {
+ // given
+ val catalogProductRepositoryImpl = FakeCatalogProductRepositoryImpl(size = 25)
+ viewModel =
+ CatalogViewModel(
+ catalogProductRepository = catalogProductRepositoryImpl,
+ cartProductRepository = FakeCartProductRepositoryImpl(),
+ recentlyViewedProductRepository =
+ FakeRecentlyViewedProductRepositoryImpl(catalogProductRepositoryImpl),
+ )
+ assertThat(viewModel.page.value).isEqualTo(0)
+
+ // when
+ viewModel.loadNextCatalogProducts()
+
+ // then
+ assertThat(viewModel.page.value).isEqualTo(1)
+ assertThat(viewModel.catalogItems.value?.size).isEqualTo(25)
+ }
+
+ @Test
+ fun `상품 목록이 20개 이상이면 더보기 버튼이 활성화된다`() {
+ val catalogProductRepositoryImpl = FakeCatalogProductRepositoryImpl(size = 20)
+ viewModel =
+ CatalogViewModel(
+ catalogProductRepository = catalogProductRepositoryImpl,
+ cartProductRepository = FakeCartProductRepositoryImpl(),
+ recentlyViewedProductRepository =
+ FakeRecentlyViewedProductRepositoryImpl(catalogProductRepositoryImpl),
+ )
+
+ // 페이지 항목 20개
+ assertThat(viewModel.catalogItems.value?.size).isEqualTo(20)
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/product/detail/DetailViewModelTest.kt b/app/src/test/java/woowacourse/shopping/product/detail/DetailViewModelTest.kt
new file mode 100644
index 000000000..ba368ef87
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/product/detail/DetailViewModelTest.kt
@@ -0,0 +1,92 @@
+package woowacourse.shopping.product.detail
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import org.junit.Rule
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import woowacourse.shopping.data.repository.FakeCartProductRepositoryImpl
+import woowacourse.shopping.data.repository.FakeCatalogProductRepositoryImpl
+import woowacourse.shopping.data.repository.FakeRecentlyViewedProductRepositoryImpl
+import woowacourse.shopping.product.catalog.ProductUiModel
+import woowacourse.shopping.util.InstantTaskExecutorExtension
+
+@ExtendWith(InstantTaskExecutorExtension::class)
+class DetailViewModelTest {
+ @get:Rule
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ private lateinit var viewModel: DetailViewModel
+
+ private val dummyProduct =
+ ProductUiModel(
+ id = 1,
+ name = "아이스 카페 아메리카노",
+ imageUrl = "https://image.istarbucks.co.kr/upload/store/skuimg/2021/04/[110563]_20210426095937947.jpg",
+ price = 10000,
+ )
+
+ private val fakeCartRepository = FakeCartProductRepositoryImpl()
+ private val fakeRecentlyViewedRepository =
+ FakeRecentlyViewedProductRepositoryImpl(FakeCatalogProductRepositoryImpl(size = 25))
+
+ private fun createViewModel() {
+ viewModel =
+ DetailViewModel(
+ productData = dummyProduct,
+ cartProductRepository = fakeCartRepository,
+ recentlyViewedProductRepository = fakeRecentlyViewedRepository,
+ )
+ }
+
+ @Test
+ fun `초기 상태는 수량과 가격이 0이다`() {
+ createViewModel()
+ assert(viewModel.quantity.value == 0)
+ assert(viewModel.price.value == 0)
+ assert(viewModel.product.value == dummyProduct)
+ }
+
+ @Test
+ fun `수량을 증가시키면 수량이 1 증가하고, 가격도 갱신된다`() {
+ createViewModel()
+ viewModel.increaseQuantity()
+ assert(viewModel.quantity.value == 1)
+ assert(viewModel.price.value == 10000)
+ }
+
+ @Test
+ fun `수량을 감소시키면 수량이 1 감소하고, 가격도 갱신된다`() {
+ createViewModel()
+ viewModel.increaseQuantity() // 1
+ viewModel.increaseQuantity() // 2
+ viewModel.decreaseQuantity() // 1
+ assert(viewModel.quantity.value == 1)
+ assert(viewModel.price.value == 10000)
+ }
+
+ @Test
+ fun `setQuantity는 productData의 수량을 설정한다`() {
+ val productWithQuantity = dummyProduct.copy(quantity = 3)
+ viewModel =
+ DetailViewModel(
+ productData = productWithQuantity,
+ cartProductRepository = fakeCartRepository,
+ recentlyViewedProductRepository = fakeRecentlyViewedRepository,
+ )
+
+ viewModel.setQuantity()
+ assert(viewModel.quantity.value == 3)
+ }
+
+ @Test
+ fun `장바구니에 추가 시 수량이 포함된 상품이 저장된다`() {
+ createViewModel()
+ viewModel.increaseQuantity() // 수량 1
+ viewModel.addToCart()
+
+ val added = fakeCartRepository.cartProducts.firstOrNull()
+ requireNotNull(added)
+ assert(added.uid == dummyProduct.id)
+ assert(added.quantity == 1)
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/util/InstantTaskExecutorExtension.kt b/app/src/test/java/woowacourse/shopping/util/InstantTaskExecutorExtension.kt
new file mode 100644
index 000000000..d9fb2f338
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/util/InstantTaskExecutorExtension.kt
@@ -0,0 +1,31 @@
+package woowacourse.shopping.util
+
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.TaskExecutor
+import org.junit.jupiter.api.extension.AfterEachCallback
+import org.junit.jupiter.api.extension.BeforeEachCallback
+import org.junit.jupiter.api.extension.ExtensionContext
+
+class InstantTaskExecutorExtension :
+ BeforeEachCallback,
+ AfterEachCallback {
+ override fun beforeEach(context: ExtensionContext?) {
+ ArchTaskExecutor.getInstance().setDelegate(
+ object : TaskExecutor() {
+ override fun executeOnDiskIO(runnable: Runnable) {
+ runnable.run()
+ }
+
+ override fun postToMainThread(runnable: Runnable) {
+ runnable.run()
+ }
+
+ override fun isMainThread(): Boolean = true
+ },
+ )
+ }
+
+ override fun afterEach(context: ExtensionContext?) {
+ ArchTaskExecutor.getInstance().setDelegate(null)
+ }
+}
diff --git a/app/src/test/java/woowacourse/shopping/util/getOrAwaitValue.kt b/app/src/test/java/woowacourse/shopping/util/getOrAwaitValue.kt
new file mode 100644
index 000000000..a34a31def
--- /dev/null
+++ b/app/src/test/java/woowacourse/shopping/util/getOrAwaitValue.kt
@@ -0,0 +1,33 @@
+package woowacourse.shopping.util
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+fun LiveData.getOrAwaitValue(
+ time: Long = 2,
+ timeUnit: TimeUnit = TimeUnit.SECONDS,
+): T {
+ var data: T? = null
+ val latch = CountDownLatch(1)
+ val observer =
+ object : Observer {
+ override fun onChanged(value: T) {
+ data = value
+ latch.countDown()
+ this@getOrAwaitValue.removeObserver(this)
+ }
+ }
+
+ this.observeForever(observer)
+
+ // Don't wait indefinitely if the LiveData is not set.
+ if (!latch.await(time, timeUnit)) {
+ throw TimeoutException("LiveData value was never set.")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return data as T
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index c7ae48efd..389283d4b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,5 +6,10 @@ plugins {
}
allprojects {
- apply(plugin = rootProject.libs.plugins.ktlint.get().pluginId)
+ apply(
+ plugin =
+ rootProject.libs.plugins.ktlint
+ .get()
+ .pluginId,
+ )
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1b9620b3a..549b18878 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,27 +9,49 @@ androidx-espresso-core = "3.6.1"
androidx-test-ext-junit = "1.2.1"
androidx-test-runner = "1.6.2"
assertj-core = "3.27.3"
+core-testing = "2.2.0"
+fragment-ktx = "1.8.7"
+glide = "4.16.0"
google-material = "1.12.0"
+gson = "2.11.0"
junit-jupiter = "5.12.0"
kotest-runner-junit5 = "5.9.1"
kotlin = "2.1.0"
ktlint = "12.1.0"
mannodermaus-junit5 = "1.7.0"
+activity = "1.10.1"
+okhttp = "4.12.0"
+retrofit = "3.0.0"
+room-runtime = "2.7.1"
+shimmer = "0.5.0"
[libraries]
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity-ktx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
+androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "core-testing" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
+androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment-ktx" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room-runtime" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room-runtime" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" }
assertj-core = { group = "org.assertj", name = "assertj-core", version.ref = "assertj-core" }
+converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
+glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
google-material = { group = "com.google.android.material", name = "material", version.ref = "google-material" }
+gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit-jupiter" }
kotest-runner-junit5 = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest-runner-junit5" }
+logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
mannodermaus-junit5-core = { group = "de.mannodermaus.junit5", name = "android-test-core", version.ref = "mannodermaus-junit5" }
mannodermaus-junit5-runner = { group = "de.mannodermaus.junit5", name = "android-test-runner", version.ref = "mannodermaus-junit5" }
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+shimmer = { module = "com.facebook.shimmer:shimmer", version.ref = "shimmer" }
[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }